"""Shared helpers for the AICEP Phase-2 reference scripts (proposed extension, v2).
Keys are only ever loaded from file paths / env vars and are never printed."""
import base64, hashlib, json, os, tempfile, time
V = 1
ALG = "age-x25519+ed25519"
KEM = "x25519"
MIN_BUCKET = 4096
class Reject(Exception):
"""Fail-closed rejection. Message never contains key material."""
def canonical(obj) -> bytes:
# RFC 8785-compatible for our schema: only str/int/bool/null/list/dict (no floats).
def chk(o):
if isinstance(o, float):
raise ValueError("floats not allowed in canonical manifest")
if isinstance(o, dict):
[chk(v) for v in o.values()]
elif isinstance(o, list):
[chk(v) for v in o]
chk(obj)
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def sha256_hex(b: bytes) -> str:
return hashlib.sha256(b).hexdigest()
def key_id(pub_raw: bytes) -> str:
return "ed25519:" + hashlib.sha256(pub_raw).hexdigest()[:16]
def pad(data: bytes) -> bytes:
need = len(data) + 4
size = MIN_BUCKET
while size < need:
size *= 2
return len(data).to_bytes(4, "big") + data + b"\0" * (size - need)
def unpad(buf: bytes) -> bytes:
if len(buf) < 4:
raise Reject("padding: too short")
n = int.from_bytes(buf[:4], "big")
if n > len(buf) - 4 or any(buf[4 + n:]):
raise Reject("padding: malformed")
return buf[4:4 + n]
def read_secret_path(env_name: str, override: str | None = None) -> bytes:
path = override or os.environ.get(env_name)
if not path:
raise SystemExit(f"missing key path: set {env_name} (a file path, never the key value)")
with open(path, "rb") as f:
return f.read()
TMPFS_TYPES = {"tmpfs", "ramfs"}
def _mount_fstype(path: str) -> str:
"""Filesystem type of the mount holding path (longest mountpoint prefix in /proc/self/mounts)."""
rp = os.path.realpath(path)
best, fs = "", ""
try:
with open("/proc/self/mounts") as f:
for line in f:
parts = line.split()
if len(parts) < 3:
continue
mp = parts[1].replace("\\040", " ")
if (rp == mp or rp.startswith(mp.rstrip("/") + "/")) and len(mp) >= len(best):
best, fs = mp, parts[2]
except OSError:
return ""
return fs
def require_tmpfs(path: str, what: str = "cleared plaintext input"):
"""Review fix 2: DLP-cleared plaintext may only live on tmpfs (e.g. /dev/shm). Refuse any other path,
including symlinks that resolve off tmpfs."""
fs = _mount_fstype(path)
if fs not in TMPFS_TYPES:
raise SystemExit(f"refusing {what} at {path!r}: not on tmpfs (got {fs or 'unknown'}); use /dev/shm")
return os.path.realpath(path)
def load_private_key(file_env: str, pass_env: str, path=None, passphrase=None, keyring_service="aicep"):
"""Review fix 1: private keys must be ENCRYPTED PEM (PKCS8 + BestAvailableEncryption).
Passphrase source, in order: explicit arg, env var <pass_env>, OS keyring (service 'aicep',
username = <file_env>) if the optional 'keyring' package is installed. Unencrypted PEM is refused."""
from cryptography.hazmat.primitives import serialization
data = read_secret_path(file_env, path)
if b"ENCRYPTED PRIVATE KEY" not in data:
raise SystemExit(f"refusing unencrypted private key in {file_env}: use an encrypted PKCS8 PEM")
pw = passphrase if passphrase is not None else os.environ.get(pass_env)
if pw is None:
try:
import keyring # optional
pw = keyring.get_password(keyring_service, file_env)
except Exception:
pw = None
if not pw:
raise SystemExit(f"no passphrase for {file_env}: set {pass_env} or store it in the OS keyring")
pw = pw.encode() if isinstance(pw, str) else pw
try:
return serialization.load_pem_private_key(data, password=pw)
except (ValueError, TypeError):
raise SystemExit(f"cannot decrypt {file_env} (wrong passphrase or corrupt key)")
class FileLock:
"""Review fix 5: exclusive fcntl lock on <path>.lock for load->check->write critical sections."""
def __init__(self, path):
self.path = os.path.abspath(path) + ".lock"
def __enter__(self):
os.makedirs(os.path.dirname(self.path), exist_ok=True)
import fcntl
self.f = open(self.path, "a+")
fcntl.flock(self.f, fcntl.LOCK_EX)
return self
def __exit__(self, *exc):
import fcntl
fcntl.flock(self.f, fcntl.LOCK_UN)
self.f.close()
def load_json(path, default=None):
if default is not None and not os.path.exists(path):
return default
with open(path, "rb") as f:
return json.loads(f.read())
def atomic_write(path: str, data: bytes):
d = os.path.dirname(os.path.abspath(path))
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp, path)
def b64(b: bytes) -> str:
return base64.b64encode(b).decode()
def unb64(s: str) -> bytes:
return base64.b64decode(s, validate=True)
# ---------------- v2.3 delivery guarantees (proposed Phase 2 extension) ----------------
SUPPORTED_V = (1, 2) # v2 = manifest may carry signed prev_batch_id
# QoP floor: the ONLY accepted quality of protection is signed + encrypted. Unsigned, unencrypted,
# or sign-only / encrypt-only batches are never ingested; they go to dlq with reason "qop".
QOP_FLOOR = "sign+encrypt"
REASONS_CRYPTO = {"folder_mismatch", "sig_fail", "hash_mismatch", "revoked", "equivocation", "qop", "replay"}
REASONS_TRANSIENT = {"ingest_error", "io_error"}
REASONS_OTHER = {"expired", "gap_timeout", "size_limit", "rejected"}
MAX_ATTEMPTS_TRANSIENT, MAX_ATTEMPTS_CRYPTO = 3, 1
RETRY_SCHEDULE = (3600, 4 * 3600, 12 * 3600) # sender resend offsets after publish (no ack yet)
HOLD_WINDOW = 1800 # receiver reorder window before human hold
def classify_reason(msg: str) -> str:
m = msg.lower()
for key, code in (("signature", "sig_fail"), ("hash", "hash_mismatch"), ("revok", "revoked"),
("replay", "replay"), ("expired", "expired"), ("oversized", "size_limit"),
("equivocation", "equivocation"), ("unsigned", "qop"), ("not canonical", "sig_fail"),
("unlisted", "hash_mismatch"), ("binding", "hash_mismatch")):
if key in m:
return code
return "rejected"
class LedgerError(Exception):
"""Ledger integrity failure: fail closed."""
MAX_TTL = 7 * 86400 # W1: maximum signed validity window (expires - created)
class Ledger:
"""Append-only, hash-chained audit log (JSON lines). Each entry carries prev = hash of previous entry.
head() goes into every receipt so both sides can cross-check history. Never contains content or keys."""
GENESIS = "0" * 64
def __init__(self, path):
self.path = path
@property
def anchor_path(self):
return self.path + ".anchor"
def entries(self):
if not os.path.exists(self.path):
return []
with open(self.path, "rb") as f:
return [json.loads(l) for l in f if l.strip()]
def anchor(self):
"""v2.4 (V6): separately written {head, count}; detects tail truncation."""
return load_json(self.anchor_path, default={"head": self.GENESIS, "count": 0})
def head(self):
e = self.entries()
return e[-1]["hash"] if e else self.GENESIS
def _anchor_ok(self):
"""W2/W3: the anchor must match what is on disk; no anchor is only OK for an empty ledger."""
es = self.entries()
if not os.path.exists(self.anchor_path):
return not es
a = self.anchor()
return a.get("count") == len(es) and a.get("head") == (es[-1]["hash"] if es else self.GENESIS)
def append(self, event: str, **fields):
try:
ok = self._anchor_ok()
except Exception:
ok = False
if not ok:
raise LedgerError(f"ledger anchor mismatch on {os.path.basename(self.path)}: refusing to append "
"(possible truncation); a human must run --reanchor-ledger --operator NAME")
return self._append(event, **fields)
def _append(self, event: str, **fields):
prev = self.head()
body = {"event": event, "prev": prev, "t": int(fields.pop("t", time.time())), **fields}
body["hash"] = sha256_hex(canonical({k: v for k, v in body.items() if k != "hash"}))
os.makedirs(os.path.dirname(os.path.abspath(self.path)), exist_ok=True)
with open(self.path, "ab") as f:
f.write(canonical(body) + b"\n")
f.flush(); os.fsync(f.fileno())
n = len(self.entries())
atomic_write(self.anchor_path, canonical({"head": body["hash"], "count": n}))
return body["hash"]
def links_ok(self) -> bool:
try:
prev = self.GENESIS
for e in self.entries():
if e.get("prev") != prev or e.get("hash") != sha256_hex(canonical({k: v for k, v in e.items() if k != "hash"})):
return False
prev = e["hash"]
return True
except Exception:
return False
def reanchor(self, operator: str, t=None):
"""W3: HUMAN-ONLY re-anchor (legacy ledger, or crash between line append and anchor write).
Internal links must still verify; the event is logged as reanchored{operator, old_count, new_count}."""
operator = operator.strip() if isinstance(operator, str) else ""
if not operator:
raise PermissionError("reanchor requires a named human operator")
if not self.links_ok():
raise LedgerError("ledger links do not verify; refusing to re-anchor")
old = self.anchor() if os.path.exists(self.anchor_path) else {"head": None, "count": None}
n = len(self.entries())
return self._append("reanchored", operator=operator, old_count=old.get("count"),
old_head=old.get("head"), new_count=n + 1,
**({"t": t} if t is not None else {}))
def verify(self, expected_head=None) -> bool:
"""Internal links + head anchor (V6). expected_head: e.g. ledger_head from the last signed receipt."""
try:
es = self.entries()
prev = self.GENESIS
for e in es:
h = e.get("hash")
if e.get("prev") != prev or h != sha256_hex(canonical({k: v for k, v in e.items() if k != "hash"})):
return False
prev = h
a = self.anchor()
except Exception:
return False # malformed line / anchor = not verified
if a.get("head") != prev or a.get("count") != len(es):
return False
if expected_head is not None and expected_head not in {e["hash"] for e in es} | {self.GENESIS}:
return False
return True
# ---------------- v2.4 shared manifest gate (V1, V5, V7, V11) ----------------
CLOCK_SKEW = 300
def _is_int(x):
return isinstance(x, int) and not isinstance(x, bool)
def check_schema(m):
"""V5: type-check signed fields up front; malformed = Reject (never ValueError/KeyError)."""
for k in ("created", "expires", "seq"):
if not _is_int(m.get(k)):
raise Reject(f"manifest schema: {k} must be an integer")
files = m.get("files")
if not isinstance(files, list) or not files:
raise Reject("manifest schema: files must be a non-empty list")
for f in files:
if not (isinstance(f, dict) and isinstance(f.get("id"), str) and f["id"]
and "/" not in f["id"] and not f["id"].startswith(".")
and _is_int(f.get("size_padded")) and f["size_padded"] > 0
and isinstance(f.get("sha256_ct"), str)):
raise Reject("manifest schema: malformed file entry")
if "prev_batch_id" in m and not isinstance(m["prev_batch_id"], str):
raise Reject("manifest schema: prev_batch_id must be a string")
def verify_manifest(mbytes, sig, registry, now):
"""Registry gate + Ed25519 over exact canonical bytes + schema. Returns (manifest, registry entry).
Raises Reject. Used by ingest() AND by the Receiver before any hold/dedup/chain decision (V1, V11)."""
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
try:
m = json.loads(mbytes)
except Exception:
raise Reject("manifest/signature unparseable")
try:
if not isinstance(m, dict) or canonical(m) != mbytes:
raise Reject("manifest is not canonical")
except ValueError:
raise Reject("manifest is not canonical")
skid = m.get("sender_key_id")
ent = registry.get(skid) if isinstance(skid, str) else None
if not ent:
raise Reject("unknown signer key")
if ent.get("state") != "verified" or not ent.get("verified_by") or not ent.get("verified_at"):
raise Reject("signer key not verified (pending)")
if ent.get("revoked_at") is not None and now >= int(ent["revoked_at"]):
raise Reject("signer key revoked")
if ent.get("expires") is not None and now >= int(ent["expires"]):
raise Reject("signer key expired in registry")
pub_raw = unb64(ent["ed25519_pub"])
if key_id(pub_raw) != skid or ent.get("sender_id") != m.get("sender_id"):
raise Reject("registry entry does not match manifest sender")
try:
Ed25519PublicKey.from_public_bytes(pub_raw).verify(sig, mbytes)
except InvalidSignature:
raise Reject("bad signature")
if m.get("v") not in SUPPORTED_V or (m.get("alg"), m.get("kem")) != (ALG, KEM):
raise Reject("unsupported version/alg")
check_schema(m)
# V7: receiver-clock bounds on the sender-chosen created
if m["created"] > now + CLOCK_SKEW:
raise Reject("manifest created in the future (clock skew bound)")
if m["expires"] <= m["created"]:
raise Reject("manifest expires <= created")
if m["expires"] - m["created"] > MAX_TTL:
raise Reject(f"manifest ttl exceeds MAX_TTL ({MAX_TTL} s)")
if ent.get("revoked_at") is not None and m["created"] >= int(ent["revoked_at"]):
raise Reject("signed at/after key revocation")
return m, ent
# ---------------- v2.5 per-agent folders ----------------
import re as _re
SLUG_RE = _re.compile(r"^[a-z0-9-]{3,64}$")
def valid_slug(s) -> bool:
"""Folder slug: lowercase [a-z0-9-], 3-64 chars, no dots or slashes (e.g. agent-alpha)."""
return isinstance(s, str) and bool(SLUG_RE.match(s))
def check_slug(s):
if not valid_slug(s):
raise ValueError(f"invalid folder slug {s!r}: need lowercase [a-z0-9-], 3-64 chars")
return s
def validate_registry_folders(registry):
"""Every entry has a valid `folder` slug, unique across the registry. Raises ValueError."""
seen = {}
for skid, ent in registry.items():
f = check_slug(ent.get("folder"))
if f in seen:
raise ValueError(f"folder {f!r} registered to two keys")
seen[f] = skid
return seen
def active_folders(registry, now):
"""Folders the receiver watches: verified, not revoked, not expired senders only. {slug: skid}."""
out = {}
for skid, ent in registry.items():
if ent.get("state") != "verified" or not ent.get("verified_by") or not ent.get("verified_at"):
continue
if ent.get("revoked_at") is not None and now >= int(ent["revoked_at"]):
continue
if ent.get("expires") is not None and now >= int(ent["expires"]):
continue
if valid_slug(ent.get("folder")):
out[ent["folder"]] = skid
return out