198 lines
9.2 KiB
Python
198 lines
9.2 KiB
Python
"""Unit tests for the seal stage. Run: python3 -m unittest discover .
|
|
|
|
Round trips use a real age identity generated with age-keygen when the
|
|
binaries are on PATH (they are on the server and in CI); the envelope
|
|
tests are skipped where age is absent, the fail-closed tests are not.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from . import seal as sl
|
|
from .catalog import catalog
|
|
from .extract import extract
|
|
from .manifest import validate_document
|
|
from .sanitize import sanitize
|
|
from .walk import walk
|
|
|
|
HAVE_AGE = bool(shutil.which("age") and shutil.which("age-keygen"))
|
|
|
|
|
|
def manifest(root, seal_to=(), stages=None):
|
|
doc = {
|
|
"freeagent": 1, "id": "t", "owner": {"host": "h"},
|
|
"scope": {"roots": [root]},
|
|
"mask": ["read", "search", "index"],
|
|
"stages": stages or ["walk", "extract", "sanitize", "catalog", "seal"], "sweep": "manual",
|
|
"sanitize": {"mode": "flag"},
|
|
"sink": {"catalog": "host", "vectors": "local", "plaintext": "never", "seal_to": list(seal_to)},
|
|
}
|
|
return validate_document(doc)
|
|
|
|
|
|
def keygen(dirpath):
|
|
ident = os.path.join(dirpath, "key.txt")
|
|
out = subprocess.run(["age-keygen", "-o", ident], capture_output=True, text=True, check=True)
|
|
pub = [l for l in (out.stderr + out.stdout).splitlines() if "age1" in l][-1].split()[-1]
|
|
return ident, pub
|
|
|
|
|
|
class Case(unittest.TestCase):
|
|
def setUp(self):
|
|
self.root = tempfile.mkdtemp(prefix="fa-seal-")
|
|
self.out = tempfile.mkdtemp(prefix="fa-sealout-")
|
|
with open(os.path.join(self.root, "note.txt"), "w", encoding="utf-8") as fh:
|
|
fh.write("Marisol's email is m@example.com and card 4111 1111 1111 1111.\n")
|
|
m = manifest(self.root)
|
|
walk(m, self.out)
|
|
extract(m, self.out, use_external=False)
|
|
sanitize(m, self.out, env={})
|
|
catalog(m, self.out, env={})
|
|
|
|
def tearDown(self):
|
|
shutil.rmtree(self.root, ignore_errors=True)
|
|
shutil.rmtree(self.out, ignore_errors=True)
|
|
|
|
|
|
class FailClosedTests(Case):
|
|
def test_no_owner_recipient(self):
|
|
# age is faked present: seal checks for it first, and this test is
|
|
# about the recipient refusal, whether or not age is installed
|
|
with mock.patch.object(sl, "tool_path", lambda n: "/usr/bin/" + n):
|
|
with self.assertRaises(sl.SealError) as cm:
|
|
sl.seal(manifest(self.root), self.out, env={})
|
|
self.assertIn("FREEAGENT_OWNER_RECIPIENT", str(cm.exception))
|
|
self.assertFalse(os.path.exists(os.path.join(self.out, "sealed")))
|
|
|
|
def test_bad_recipient_strings(self):
|
|
env = {"FREEAGENT_OWNER_RECIPIENT": "not-a-key"}
|
|
with mock.patch.object(sl, "tool_path", lambda n: "/usr/bin/" + n):
|
|
with self.assertRaises(sl.SealError) as cm:
|
|
sl.seal(manifest(self.root), self.out, env=env)
|
|
self.assertIn("not an age recipient", str(cm.exception))
|
|
env = {"FREEAGENT_OWNER_RECIPIENT": "age1" + "q" * 58}
|
|
with self.assertRaises(sl.SealError) as cm:
|
|
sl.recipients_for(manifest(self.root, seal_to=["partner-key"]), env)
|
|
self.assertIn("partner-key", str(cm.exception))
|
|
|
|
def test_no_age_on_path(self):
|
|
env = {"FREEAGENT_OWNER_RECIPIENT": "age1" + "q" * 58}
|
|
with mock.patch.object(sl, "tool_path", lambda n: None):
|
|
with self.assertRaises(sl.SealError) as cm:
|
|
sl.seal(manifest(self.root), self.out, env=env)
|
|
self.assertIn("age", str(cm.exception))
|
|
|
|
def test_seal_not_in_stages(self):
|
|
# (a `catalog: host` manifest cannot even omit seal — the schema refuses it)
|
|
m = validate_document({
|
|
"freeagent": 1, "id": "t", "owner": {"host": "h"}, "scope": {"roots": [self.root]},
|
|
"mask": ["read", "index"], "stages": ["walk", "extract", "sanitize", "catalog"],
|
|
"sweep": "manual", "sanitize": {"mode": "flag"},
|
|
"sink": {"catalog": "local", "vectors": "local", "plaintext": "never"}})
|
|
with self.assertRaises(sl.SealError):
|
|
sl.seal(m, self.out, env={"FREEAGENT_OWNER_RECIPIENT": "age1" + "q" * 58})
|
|
|
|
def test_bundle_allowlist(self):
|
|
plain, inv = sl.build_bundle(manifest(self.root), self.out, "20260828T000000Z")
|
|
with tarfile.open(fileobj=io.BytesIO(plain)) as tar:
|
|
names = tar.getnames()
|
|
self.assertIn("catalog.jsonl", names)
|
|
self.assertIn("catalog.dirs.jsonl", names)
|
|
self.assertIn("manifest.json", names)
|
|
self.assertIn("seal.manifest.json", names)
|
|
self.assertIn("walk.summary.json", names)
|
|
self.assertIn("sanitize.summary.json", names)
|
|
for forbidden in ("walk.jsonl", "extract.jsonl", "sanitize.jsonl", "vectors.db", "vault.db"):
|
|
self.assertFalse(any(n.endswith(forbidden) for n in names), forbidden)
|
|
self.assertFalse(any(n.startswith(("extract/", "sanitize/")) for n in names))
|
|
# nothing in the bundle is a value
|
|
self.assertNotIn(b"m@example.com", plain)
|
|
self.assertNotIn(b"4111", plain)
|
|
self.assertIn(b"[PII_EMAIL_1]", plain) # sanitized head is there, as designed
|
|
self.assertEqual(set(inv["members"]), set(names))
|
|
|
|
def test_bundle_reproducible(self):
|
|
a, _ = sl.build_bundle(manifest(self.root), self.out, "20260828T000000Z")
|
|
b, _ = sl.build_bundle(manifest(self.root), self.out, "20260828T000000Z")
|
|
self.assertEqual(a, b)
|
|
|
|
|
|
@unittest.skipUnless(HAVE_AGE, "age / age-keygen not on PATH")
|
|
class RoundTripTests(Case):
|
|
def test_seal_and_unseal(self):
|
|
ident, pub = keygen(self.out)
|
|
other_ident, other_pub = keygen(tempfile.mkdtemp(prefix="fa-k2-"))
|
|
env = {"FREEAGENT_OWNER_RECIPIENT": pub}
|
|
s = sl.seal(manifest(self.root, seal_to=[other_pub]), self.out, env=env, now="20260828T000000Z")
|
|
sealed = os.path.join(self.out, s["file"])
|
|
self.assertTrue(os.path.exists(sealed))
|
|
self.assertEqual(s["recipients"], [pub, other_pub])
|
|
self.assertGreater(s["bytes_sealed"], 0)
|
|
self.assertIsNone(s["signature"])
|
|
with open(sealed, "rb") as fh:
|
|
blob = fh.read()
|
|
self.assertTrue(blob.startswith(b"age-encryption.org/v1"))
|
|
self.assertNotIn(b"PII_EMAIL", blob) # sealed: the catalog text is not visible
|
|
with open(os.path.join(self.out, "sealed", "latest.json")) as fh:
|
|
self.assertEqual(json.load(fh)["file"], s["file"])
|
|
# both recipients can open it; a stranger cannot
|
|
for who in (ident, other_ident):
|
|
dest = tempfile.mkdtemp(prefix="fa-un-")
|
|
inv = sl.unseal(sealed, who, dest)
|
|
self.assertEqual(inv["manifest_id"], "t")
|
|
with open(os.path.join(dest, "catalog.jsonl"), encoding="utf-8") as fh:
|
|
self.assertIn("[PII_EMAIL_1]", fh.read())
|
|
shutil.rmtree(dest, ignore_errors=True)
|
|
stranger, _ = keygen(tempfile.mkdtemp(prefix="fa-k3-"))
|
|
with self.assertRaises(sl.SealError):
|
|
sl.unseal(sealed, stranger, tempfile.mkdtemp(prefix="fa-un-"))
|
|
|
|
def test_owner_recipient_deduped_and_signature(self):
|
|
ident, pub = keygen(self.out)
|
|
key = os.path.join(self.out, "sign.pem")
|
|
subprocess.run(["openssl", "ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", key],
|
|
check=True, capture_output=True)
|
|
env = {"FREEAGENT_OWNER_RECIPIENT": pub, "SEAL_SIGN_KEY": key}
|
|
s = sl.seal(manifest(self.root, seal_to=[pub]), self.out, env=env)
|
|
self.assertEqual(s["recipients"], [pub])
|
|
sig = os.path.join(self.out, "sealed", s["signature"])
|
|
self.assertTrue(os.path.exists(sig))
|
|
# the signature verifies against the public half
|
|
pubkey = os.path.join(self.out, "sign.pub")
|
|
subprocess.run(["openssl", "ec", "-in", key, "-pubout", "-out", pubkey],
|
|
check=True, capture_output=True)
|
|
v = subprocess.run(["openssl", "dgst", "-sha256", "-verify", pubkey, "-signature", sig,
|
|
os.path.join(self.out, s["file"])], capture_output=True, text=True)
|
|
self.assertEqual(v.returncode, 0, v.stderr)
|
|
|
|
def test_tampered_member_refused(self):
|
|
ident, pub = keygen(self.out)
|
|
env = {"FREEAGENT_OWNER_RECIPIENT": pub}
|
|
sl.seal(manifest(self.root), self.out, env=env)
|
|
# rebuild a bundle with a modified catalog but the old inventory, seal it, and unseal
|
|
plain, _ = sl.build_bundle(manifest(self.root), self.out, "x")
|
|
buf = io.BytesIO()
|
|
with tarfile.open(fileobj=io.BytesIO(plain)) as src, tarfile.open(fileobj=buf, mode="w") as dst:
|
|
for m in src.getmembers():
|
|
data = src.extractfile(m).read()
|
|
if m.name == "catalog.jsonl":
|
|
data = data + b"\n{\"forged\": true}\n"
|
|
m.size = len(data)
|
|
dst.addfile(m, io.BytesIO(data))
|
|
bad = os.path.join(self.out, "bad.tar.age")
|
|
subprocess.run(["age", "-r", pub, "-o", bad], input=buf.getvalue(), check=True)
|
|
with self.assertRaises(sl.SealError) as cm:
|
|
sl.unseal(bad, ident, tempfile.mkdtemp(prefix="fa-un-"))
|
|
self.assertIn("does not match the inventory", str(cm.exception))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|