feat(v2): complete v2 reimplementation
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from backup_tool.cli import build_alembic_config, build_parser
|
||||
from backup_tool.cli import main as cli_main
|
||||
from backup_tool.db.engine import create_engine
|
||||
from backup_tool.db.models import Repository, RepositoryDataKeyEpoch
|
||||
from backup_tool.repository import initialize
|
||||
from backup_tool.security.recovery_bundle import (
|
||||
RecoveryBundleError,
|
||||
RecoveryBundlePathError,
|
||||
decrypt_bundle,
|
||||
encrypt_bundle,
|
||||
write_bundle_exclusive,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from tests.conftest import make_settings
|
||||
|
||||
PASSPHRASE = b"correct horse battery staple"
|
||||
|
||||
|
||||
def _passphrase_fd(value: bytes) -> int:
|
||||
read_fd, write_fd = os.pipe()
|
||||
os.write(write_fd, value + b"\n")
|
||||
os.close(write_fd)
|
||||
return read_fd
|
||||
|
||||
|
||||
def test_recovery_bundle_rejects_wrong_passphrase_tampering_and_invalid_kdf() -> None:
|
||||
bundle = encrypt_bundle({"catalog": {"version": 1}, "keys": []}, PASSPHRASE)
|
||||
tampered = bundle[:-1] + bytes([bundle[-1] ^ 1])
|
||||
unsupported_kdf = bundle[:6] + b"\x02" + bundle[7:]
|
||||
|
||||
for encoded, passphrase in (
|
||||
(bundle, b"wrong passphrase"),
|
||||
(tampered, PASSPHRASE),
|
||||
(unsupported_kdf, PASSPHRASE),
|
||||
):
|
||||
with pytest.raises(RecoveryBundleError, match="^recovery bundle is invalid$"):
|
||||
decrypt_bundle(encoded, passphrase)
|
||||
|
||||
|
||||
def test_recovery_bundle_output_is_exclusive_and_does_not_follow_symlinks(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
bundle = encrypt_bundle({"catalog": {"version": 1}, "keys": []}, PASSPHRASE)
|
||||
existing = tmp_path / "existing.btrec"
|
||||
existing.write_bytes(b"keep")
|
||||
|
||||
with pytest.raises(RecoveryBundlePathError, match="^recovery bundle output is unsafe$"):
|
||||
write_bundle_exclusive(existing, bundle)
|
||||
assert existing.read_bytes() == b"keep"
|
||||
|
||||
target = tmp_path / "target.btrec"
|
||||
target.write_bytes(b"keep")
|
||||
symlink = tmp_path / "link.btrec"
|
||||
symlink.symlink_to(target)
|
||||
with pytest.raises(RecoveryBundlePathError, match="^recovery bundle output is unsafe$"):
|
||||
write_bundle_exclusive(symlink, bundle)
|
||||
assert target.read_bytes() == b"keep"
|
||||
|
||||
|
||||
def test_cli_recovery_export_validate_uses_fd_and_hides_plaintext_keys(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
settings = make_settings(tmp_path)
|
||||
command.upgrade(build_alembic_config(settings), "head")
|
||||
initialized = initialize(settings, "encrypted", "none", "aes-256-gcm")
|
||||
assert initialized.data_key_id is not None
|
||||
assert initialized.data_key_path is not None
|
||||
|
||||
async def create_repository() -> None:
|
||||
engine = create_engine(settings)
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
try:
|
||||
async with sessions() as db:
|
||||
repository = Repository(
|
||||
name="encrypted",
|
||||
root=str(initialized.root),
|
||||
format_version=initialized.format_version,
|
||||
compression=initialized.compression,
|
||||
encryption=initialized.encryption,
|
||||
signing_key_id=initialized.signing_key_id,
|
||||
signing_public_key=initialized.signing_public_key,
|
||||
active_data_key_id=initialized.data_key_id,
|
||||
)
|
||||
db.add(repository)
|
||||
await db.flush()
|
||||
db.add(
|
||||
RepositoryDataKeyEpoch(
|
||||
repository_id=repository.id,
|
||||
key_id=initialized.data_key_id,
|
||||
state="active",
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(create_repository())
|
||||
output = tmp_path / "recovery.btrec"
|
||||
export_fd = _passphrase_fd(PASSPHRASE)
|
||||
try:
|
||||
assert (
|
||||
cli_main(
|
||||
[
|
||||
"admin",
|
||||
"recovery",
|
||||
"export",
|
||||
"--output",
|
||||
str(output),
|
||||
"--passphrase-fd",
|
||||
str(export_fd),
|
||||
],
|
||||
settings=settings,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
os.close(export_fd)
|
||||
export_output = capsys.readouterr().out
|
||||
bundle = output.read_bytes()
|
||||
assert bundle.startswith(b"BTREC\x01")
|
||||
assert initialized.data_key_path.read_bytes() not in bundle
|
||||
signing_key_path = (
|
||||
settings.data_dir / "repository-keys" / f"{initialized.repository_id}.ed25519"
|
||||
)
|
||||
assert signing_key_path.read_bytes() not in bundle
|
||||
assert "key" not in export_output.lower()
|
||||
|
||||
validate_fd = _passphrase_fd(PASSPHRASE)
|
||||
try:
|
||||
assert (
|
||||
cli_main(
|
||||
[
|
||||
"admin",
|
||||
"recovery",
|
||||
"validate",
|
||||
"--input",
|
||||
str(output),
|
||||
"--passphrase-fd",
|
||||
str(validate_fd),
|
||||
],
|
||||
settings=settings,
|
||||
)
|
||||
== 0
|
||||
)
|
||||
finally:
|
||||
os.close(validate_fd)
|
||||
assert capsys.readouterr().out == '{"repositories": 1, "status": "valid"}\n'
|
||||
|
||||
parser = build_parser()
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["admin", "recovery", "export", "--passphrase", "not-allowed"])
|
||||
Reference in New Issue
Block a user