AICEPAICEP AICEP 0.1 — Public Draft (RFC)
AICEP is an experimental public draft, provided “as is” and without warranty. Use it at your own risk. We test it extensively, but testing is not a guarantee. Full disclaimer

← AICEP home · Runbook index

reference/aicep_pub.py

Download aicep_pub.py

#!/usr/bin/env python3
"""AICEP Phase-2 reference publisher (proposed extension, follows encryption-scheme.md v2).

Input: a JSON list of objects {name, type, classification, body, supersedes?} that ALREADY
passed the DLP/classification gate. Output: pub/<compartment>/<batch_uuid>/ containing
<file_uuid>.age, meta.age, manifest.json (canonical) and manifest.json.sig (detached Ed25519).

Keys: AICEP_SIGN_KEY_FILE = path to an ENCRYPTED Ed25519 private key (PKCS8 PEM); passphrase from
AICEP_SIGN_KEY_PASSPHRASE or the OS keyring. Unencrypted keys are refused. --in must be on tmpfs. Recipients are PUBLIC
age strings (AICEP_RECIPIENTS, comma-separated, or --recipient). Nothing secret is printed.
"""
import argparse, json, os, shutil, sys, tempfile, time, uuid
import pyrage
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from aicep_common import (ALG, KEM, V, FileLock, Reject, atomic_write, b64, canonical, key_id, load_json,
                          load_private_key, pad, require_tmpfs, sha256_hex)

# Review fix 5: sender-side limits mirror the receiver's (aicep_sub MAX_*), so oversize fails early.
MAX_OBJECTS = 20
MAX_OBJ_PADDED = 64 * 1024
MAX_BATCH_PADDED = 512 * 1024


def load_signer(path=None, passphrase=None) -> Ed25519PrivateKey:
    k = load_private_key("AICEP_SIGN_KEY_FILE", "AICEP_SIGN_KEY_PASSPHRASE", path, passphrase)
    if not isinstance(k, Ed25519PrivateKey):
        raise SystemExit("signing key is not Ed25519")
    return k


def publish(objects, recipients, signer, sender_id, compartment, out_root, state_path,
            ttl=3600, now=None, seq=None, batch_id=None, prev_batch_id=None, enforce_limits=True):
    """enforce_limits=False exists ONLY so tests can play a hostile sender against the receiver's caps."""
    now = int(now if now is not None else time.time())
    if enforce_limits and len(objects) > MAX_OBJECTS:
        raise Reject(f"oversized batch: {len(objects)} objects > {MAX_OBJECTS}")
    with FileLock(state_path):
        return _publish(objects, recipients, signer, sender_id, compartment, out_root, state_path,
                        ttl, now, seq, batch_id, prev_batch_id, enforce_limits)


def _publish(objects, recipients, signer, sender_id, compartment, out_root, state_path,
             ttl, now, seq, batch_id, prev_batch_id, enforce_limits=True):
    pub_raw = signer.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
    skid = key_id(pub_raw)
    state = load_json(state_path, default={"last_sent_seq": {}})
    if seq is None:
        seq = int(state["last_sent_seq"].get(skid, 0)) + 1
    batch_id = batch_id or str(uuid.uuid4())
    recips = [pyrage.x25519.Recipient.from_str(r) for r in recipients]

    def enc(envelope: dict):
        padded = pad(canonical(envelope))
        if enforce_limits and len(padded) > MAX_OBJ_PADDED:
            raise Reject(f"oversized object body ({len(padded)} padded bytes > {MAX_OBJ_PADDED})")
        ct = pyrage.encrypt(padded, recips)  # in memory; plaintext never touches disk
        return ct, len(padded)

    out_files, entries, meta_items = {}, [], {}
    for o in objects:
        fid = str(uuid.uuid4())
        env = {"batch_id": batch_id, "file_id": fid, "seq": seq, "type": o["type"],
               "classification": o["classification"], "body": o["body"]}
        ct, sp = enc(env)
        out_files[f"{fid}.age"] = ct
        entries.append({"id": fid, "sha256_ct": sha256_hex(ct), "size_padded": sp})
        meta_items[fid] = {"name": o.get("name"), "type": o["type"],
                           "classification": o["classification"], "supersedes": o.get("supersedes")}
    meta_env = {"batch_id": batch_id, "file_id": "meta", "seq": seq, "type": "aicep.meta",
                "classification": "meta", "body": meta_items}
    ct, sp = enc(meta_env)
    out_files["meta.age"] = ct
    entries.append({"id": "meta", "sha256_ct": sha256_hex(ct), "size_padded": sp})
    if enforce_limits and sum(e["size_padded"] for e in entries) > MAX_BATCH_PADDED:
        raise Reject(f"oversized batch > {MAX_BATCH_PADDED} padded bytes")

    manifest = {"v": 2 if prev_batch_id else V, "alg": ALG, "kem": KEM, "sender_id": sender_id, "sender_key_id": skid,
                "recipient_key_ids": sorted(recipients), "compartment": compartment,
                "batch_id": batch_id, "seq": seq, "created": now, "expires": now + int(ttl),
                "files": sorted(entries, key=lambda e: e["id"])}
    if prev_batch_id:
        manifest["prev_batch_id"] = prev_batch_id          # v2.3: signed chain link
    mbytes = canonical(manifest)
    sig = signer.sign(mbytes)

    # Build in a staging dir next to the target, then atomic rename into pub/.
    dest_parent = os.path.join(out_root, compartment)
    os.makedirs(dest_parent, exist_ok=True)
    stage = tempfile.mkdtemp(dir=dest_parent, prefix=".staging-")
    for name, data in out_files.items():
        with open(os.path.join(stage, name), "wb") as f:
            f.write(data)
    with open(os.path.join(stage, "manifest.json"), "wb") as f:
        f.write(mbytes)
    with open(os.path.join(stage, "manifest.json.sig"), "w") as f:
        f.write(b64(sig) + "\n")
    dest = os.path.join(dest_parent, batch_id)
    if os.path.exists(dest):            # v2.3: rebuilding a staged (never-completed) batch_id
        shutil.rmtree(dest)
    os.rename(stage, dest)
    state["last_sent_seq"][skid] = max(seq, int(state["last_sent_seq"].get(skid, 0)))
    atomic_write(state_path, json.dumps(state, indent=2).encode())
    return {"batch_dir": dest, "batch_id": batch_id, "seq": seq, "sender_key_id": skid,
            "files": len(objects)}


class Outbox:
    """v2.3 sender outbox. Seq is RESERVED and logged 'staged' before anything is built or uploaded;
    'published' only after the transport durably accepted it. A reserved seq is reused only for the
    same batch_id (crash recovery). Resends follow RETRY_SCHEDULE; then 'unconfirmed' alert.
    Receipts are cumulative (acked_through_seq)."""

    def __init__(self, path, signer, sender_id, ledger_path, now=time.time, folder=None):
        from aicep_common import Ledger, check_slug
        self.folder = check_slug(folder)        # v2.5: this sender's registered pub/<folder>
        self.path, self.signer, self.sender_id, self.now = path, signer, sender_id, now
        self.ledger = Ledger(ledger_path)
        pub_raw = signer.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
        self.skid = key_id(pub_raw)

    def _load(self):
        return load_json(self.path, default={"reserved_seq": 0, "batches": {}, "last_published": None})

    def _save(self, st):
        atomic_write(self.path, json.dumps(st, indent=2, sort_keys=True).encode())

    def reserve(self):
        st = self._load()
        st["reserved_seq"] += 1
        bid = str(uuid.uuid4())
        st["batches"][bid] = {"seq": st["reserved_seq"], "status": "staged", "staged_at": int(self.now())}
        self._save(st)
        self.ledger.append("staged", batch_id=bid, seq=st["reserved_seq"], t=int(self.now()))
        return bid, st["reserved_seq"]

    def send(self, objects, recipients, compartment, adapter, work_root, ttl=3600, batch_id=None):
        st = self._load()
        if batch_id is None:
            batch_id, seq = self.reserve()
        else:                                          # resume a staged batch after a crash
            ent = st["batches"].get(batch_id)
            if not ent or ent["status"] != "staged":
                raise ValueError("only a staged batch_id can be resumed")
            seq = ent["seq"]
        st = self._load()
        prev = st.get("last_published") or "genesis"   # v2.4 (V3): chain starts at the first batch
        now = int(self.now())
        res = publish(objects, recipients, self.signer, self.sender_id, compartment, work_root,
                      os.path.join(work_root, ".pubstate.json"), ttl=ttl, now=now, seq=seq,
                      batch_id=batch_id, prev_batch_id=prev)
        adapter.publish(res["batch_dir"], {"compartment": compartment, "batch_id": batch_id, "seq": seq,
                                           "folder": self.folder})
        st = self._load()
        st["batches"][batch_id].update(status="published", published_at=now, expires=now + ttl,
                                       resends=0, batch_dir=res["batch_dir"], compartment=compartment)
        st["last_published"] = batch_id
        self._save(st)
        self.ledger.append("published", batch_id=batch_id, seq=seq, t=now)
        return res

    def staged(self):
        return [b for b, e in self._load()["batches"].items() if e["status"] == "staged"]

    def due(self, adapter=None):
        """Run the retry schedule. Returns actions taken: ('resend', bid) / ('unconfirmed', bid)."""
        from aicep_common import RETRY_SCHEDULE
        st, now, acts = self._load(), int(self.now()), []
        for bid, e in sorted(st["batches"].items(), key=lambda kv: kv[1]["seq"]):
            if e["status"] != "published":
                continue
            k = e.get("resends", 0)
            final = e["published_at"] + RETRY_SCHEDULE[-1] + 3600     # 1 h grace after last resend
            if now >= e["expires"] or (k >= len(RETRY_SCHEDULE) and now >= final):
                e["status"] = "unconfirmed"; acts.append(("unconfirmed", bid))
                self.ledger.append("unconfirmed", batch_id=bid, seq=e["seq"], t=now)
                continue
            if k < len(RETRY_SCHEDULE) and now >= e["published_at"] + RETRY_SCHEDULE[k]:
                e["resends"] = k + 1
                if adapter is not None:
                    adapter.publish(e["batch_dir"], {"compartment": e["compartment"], "batch_id": bid, "seq": e["seq"],
                                                                  "folder": self.folder})
                acts.append(("resend", bid))
                self.ledger.append("resend", batch_id=bid, seq=e["seq"], attempt=k + 1, t=now)
        self._save(st)
        return acts

    def on_receipt(self, receipt: dict, sig_b64: str, receiver_pub_raw: bytes):
        """Verify the receiver's signed cumulative receipt; mark every seq <= acked_through_seq acked."""
        from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
        from aicep_common import unb64
        Ed25519PublicKey.from_public_bytes(receiver_pub_raw).verify(unb64(sig_b64), canonical(receipt))
        st, n = self._load(), int(receipt.get("acked_through_seq", 0))
        for bid, e in st["batches"].items():
            if e["status"] in ("published", "unconfirmed") and e["seq"] <= n:
                e["status"] = "acked"
        self._save(st)
        self.ledger.append("receipt", acked_through_seq=n, peer_ledger_head=receipt.get("ledger_head"),
                           t=int(self.now()))
        return n


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--in", dest="inp", required=True, help="JSON list of DLP-cleared objects")
    ap.add_argument("--out", required=True, help="pub/ root (batch lands in <out>/<folder>/<batch_id>)")
    ap.add_argument("--folder", required=True, help="v2.5: this sender's registered folder slug, e.g. agent-alpha")
    ap.add_argument("--compartment", required=True)
    ap.add_argument("--sender-id", required=True)
    ap.add_argument("--recipient", action="append", default=[], help="age1... public recipient")
    ap.add_argument("--state", default=os.environ.get("AICEP_PUB_STATE", "pub_state.json"))
    ap.add_argument("--ttl", type=int, default=3600)
    a = ap.parse_args()
    from aicep_common import check_slug
    try:
        check_slug(a.folder)
    except ValueError as e:
        sys.exit(str(e))
    recips = a.recipient or [r for r in os.environ.get("AICEP_RECIPIENTS", "").split(",") if r]
    if not recips:
        sys.exit("no recipients: pass --recipient or set AICEP_RECIPIENTS")
    inp = require_tmpfs(a.inp)          # review fix 2: cleared plaintext only on tmpfs
    res = publish(load_json(inp), recips, load_signer(), a.sender_id, a.compartment,
                  os.path.join(a.out, a.folder), a.state, a.ttl)
    print(json.dumps(res))  # publish log row: ids and counts only, no secrets


if __name__ == "__main__":
    main()