Message Signing
A signed message proves which key produced it. Since v0.4.0, any inbox message can carry an Ed25519 signature, and receivers can verify it before parsing a single byte of YAML — against their own local key pins, with no network at verify time. Signing is opt-in on both sides: unsigned fleets are unaffected, and a receiver without a verify knob treats the signature as an ordinary optional field.
Verification runs in the mode each receiver configures — off, warn, or, since v0.4.2, enforce. In warn mode, every outcome — unsigned, signed-verified, signed-unknown-kid, signed-INVALID, signed-REVOKED — produces an annotation and an audit block; none of them rejects a message or changes how it is processed. In enforce mode, the autonomy gate invokes verification at message intake, before any evaluation: only a signed-verified message proceeds, and every other outcome quarantines a mode-600 evidence copy into the receiver’s dead_letter/, evaluates nothing, and exits 3 with an intake_rejected decision object. Rejection is mechanism, not per-receiver diligence. In every mode, a verified signature is a recorded fact about who signed, not a permission — identity never grants authority.
Wire format: the auth trailer
Section titled “Wire format: the auth trailer”A signed message is the ordinary rendered message followed by exactly one final physical line, followed by exactly one LF, with no content after it:
auth: "<base64url of a detached General JWS JSON: {signatures: [{protected, signature} x 1-8]}>"The signature covers the exact raw prefix bytes of the file — everything before the auth line. Byte-different means different message: there is no canonicalization, and transports must preserve or wrap the bytes, never re-emit them. Trailer encodings are canonical-only — the auth value and each signature must be the canonical unpadded base64url spelling of their bytes, so one signed message has exactly one wire spelling. An encoding alias fails verification as signed-INVALID.
The JOSE profile is locked: EdDSA (Ed25519) only, crit: ["oacp"], keys named by kid — the RFC 7638 JWK thumbprint — and never carried in the message. Sender signing is config-gated off by default (signing.sign_messages); receivers opt in per-agent via signing.verify_mode: off | warn | enforce in agents/<receiver>/config.yaml.
Verify-before-parse
Section titled “Verify-before-parse”oacp verify extracts the trailer from the raw bytes with strict last-line rules, runs bounded structural checks, verifies the signature over the exact raw prefix, then cross-checks the claimed sender against the verified identity — all before any YAML parsing. The pins consulted are the receiver’s own allowed_signers.yaml and nothing else.
$ oacp verify agents/claude/inbox/20260804_iris_task_request.yaml \ --project my-project --receiver claude[oacp-auth] signed-verified signer=urn:oacp:agent:…:iris kid=8eVCNRv7uT62…A byte-tampered message can be set aside with --quarantine: a no-clobber evidence copy lands in dead_letter/ while the original stays untouched. In warn mode this is evidence preservation, not rejection — the message still processes normally. Under enforce, quarantine is automatic: intake rejection lands the evidence copy in dead_letter/ before the message is evaluated at all.
Since v0.4.2, the same discipline covers every non-gate read path through one shared receive boundary — the oacp inbox lister, the oacp watch event emitter, the send helper’s parent-message lookup, and the trust drift report’s traffic probe all verify one bounded snapshot of a message before parsing it. Under enforce, an unverified message surfaces as a held row or event built from filesystem metadata only — no attacker-controlled field is parsed or surfaced — never donates thread identity to replies, and never counts as sender liveness. Verification and use consume the same single read, closing the window where a file could be swapped between the two, and the audit record’s message_sha256 names the verified snapshot bytes.
Trust root
Section titled “Trust root”Two files, deliberately asymmetric in authority:
| File | Location | Authority |
|---|---|---|
catalog.yaml | projects/<project>/trust/ | Zero. A distribution catalog: records signer identities so receivers have a local place to import from. Being cataloged grants nothing. |
allowed_signers.yaml | projects/<project>/agents/<receiver>/trust/ | All of it. The receiver’s own pins are the only thing consulted at verify time. No network, ever. |
A message from a cataloged-but-unpinned key still annotates signed-unknown-kid. An unpinned catalog identity is a legitimate resting state; a pinned identity missing from the catalog means authority was granted to an identity that was never recorded — that is the drift oacp doctor reports, alongside integrity errors. Doctor also surfaces the operational half of the asymmetry as the advisory catalog-not-pinned: a cataloged identity a receiver does not pin escalates from note to warning when it shows a liveness signal (another receiver pins it, or the receiver’s inbox holds traffic from that agent). signed-unknown-kid annotations on live traffic while the trust root otherwise reports clean are its signature. The advisory never makes pinning mandatory.
Pin file format (v1)
Section titled “Pin file format (v1)”Both trust files are format-versioned and CLI-managed — writers re-emit the whole file. The domain: and instance: columns are reserved from day one and unused today: they are shape-checked and carry no semantics until a future format version assigns them some, so the day cross-domain or per-machine trust arrives, no pin file needs rewriting.
# agents/<receiver>/trust/allowed_signers.yamlversion: 1signers: - agent: iris domain: <trust-domain uuid> # reserved instance: <machine-instance uuid> # reserved kid: <RFC 7638 thumbprint, 43-char base64url> jwk: {kty: OKP, crv: Ed25519, x: <base64url>} status: active # active | revokedImport flow
Section titled “Import flow”# on the signer's machine — mints the keypair + a public stuboacp key gen --agent iris# → $OACP_HOME/keys/<domain>/iris/<instance>/<kid>.json (private, 0600)# → $OACP_HOME/keys/<domain>/iris/<instance>/<kid>.pub.json (public stub)
# on the receiver's side — catalog + pin in one stepoacp trust import /path/to/<kid>.pub.json --project my-project --agent claude
# record identity only, grant nothingoacp trust import /path/to/<kid>.pub.json --project my-project --catalog-only
# inspect / auditoacp trust list --project my-projectoacp doctor --project my-project # catalog-vs-pins drift checkoacp trust import is integrity-checked end to end. It refuses: a stub whose kid is not the thumbprint of its jwk, a public key that is not the canonical encoding (one key has exactly one spelling and one kid), any jwk carrying a private component, a same-kid entry that differs from what is already recorded, and — always — reactivating a revoked pin. Revocation is a receiver decision; re-trusting requires removing the entry manually. The whole catalog-then-pins transaction holds a project trust lock, and the pin reader enforces the same strict profile at the verify boundary — a corrupt entry makes the whole file unusable rather than silently trusted.
How a public stub travels between machines is out of scope: any channel the operator already trusts for configuration works, because the pins are receiver-local either way.
Policy-file signing
Section titled “Policy-file signing”Since v0.4.2, the trust chain covers the policy files themselves. oacp trust sign-policy signs a receiver’s config.yaml and trust/allowed_signers.yaml with the receiver’s own local key:
oacp trust sign-policy --project my-project --agent claudeThe policy profile is deliberately distinct from the message profile — oacp-policy+yaml / urn:oacp:policy:v1 — so a message signature can never be replayed as a policy signature or vice versa. Each signature also binds its target context ({project, receiver, kind}) in the protected header: a signed policy cannot be replayed across projects, across receivers, or between config.yaml and allowed_signers.yaml.
Loaders verify at load time, and the result is recorded: autonomy audit records carry a policy_auth block binding policy_sha256 to an authorized signer. A tampered config.yaml fails closed with the pinned reason code policy_auth_invalid before anything — including its own verify_mode — reads it; a tampered allowed_signers.yaml makes the trust root unusable, and under enforce everything then rejects. A receiver config that fails policy authorization cannot choose a weaker verify mode — the receive paths fail closed to enforce.
sign-policy also enrolls each target in a machine-local registry ($OACP_HOME/keys/policy_enrollment.json). Once enrolled, a policy file without a verifiable signature is invalid, never unsigned — stripping a trailer stops being a downgrade path. Trust mutations (trust import / revoke) atomically re-sign an enrolled trust root or refuse the write. Unsigned policy files on never-enrolled targets remain loadable with the unsigned state recorded, so the bootstrap order is: init → key gen → sign-policy.
Receiver audit stamping
Section titled “Receiver audit stamping”Receivers that keep autonomy audit records stamp the verification outcome into the record with the same invocation that verifies the message:
oacp verify <message.yaml> --project <p> --receiver <r> \ --attach-audit "$OACP_HOME/projects/<p>/agents/<r>/audit/autonomy_decisions/<record>.yaml"This is the one supported stamping path. It writes the block under the shared audit lock, atomically, and refuses to overwrite a recorded block. Hand-writing a message_auth block — or free-text prose about the verification — is the anti-pattern: hand-shaped variants drift across records and instrumentation cannot parse them.
The canonical location is result.message_auth inside the schema-v2 audit record, and the canonical shape is exactly what verification returns:
result: message_auth: status: verified # unsigned | verified | untrusted | invalid | revoked | unsupported alg: EdDSA scheme: raw-prefix-v1 claimed_sender: <agent name from the signed prefix> verified_signer: <urn:oacp:agent:...> # null unless verified verified_instance: <urn:uuid:...> # null unless verified kid: <RFC 7638 thumbprint> # null unless verified payload_sha256: <hex digest of the signed prefix bytes> trust_source: <pins path consulted> signatures_checked: - {kid: ..., agent: ..., outcome: verified} reason: <string or null> verified_at_utc: "<ISO 8601 Z>"Warn-mode semantics carry through unchanged: the block records identity and grants no authority — gates and instrumentation consume it as telemetry only.
Key management
Section titled “Key management”- Keys are per-machine and never leave
$OACP_HOME/keys/. They are never synced and never committed. The memory-sync allowlist structurally excludeskeys/on the push side, and the canonical workspace.gitignorecarries an explicitkeys/deny line thatoacp doctorchecks fleet-wide. - The 0600 file keystore is the floor, not the design. Private key files are created
0600under0700directories and loaded only after a mode check. The backend is pluggable by design: messages reference keys bykidonly, so swapping the file keystore for an OS keychain or vault changes no wire bytes and invalidates no pins. - Same-UID threat model, stated plainly: any process running as the same user can read these key files. On a shared machine, cross-agent impersonation — one local agent signing as another — is not cryptographically prevented in any mode. The signature attests to a key, and key custody on-machine is only as strong as the file permissions. This is an accepted, documented limitation; keychain and vault-backed signer backends are planned behind the same
kidseam.
Rotation and revocation
Section titled “Rotation and revocation”Rotation is overlap-based: oacp key gen mints a new key alongside the old, and senders sign with every local key (capped at 8), so a message verifies against either pin while receivers import the new stub. Remove the retired key file to stop signing with it.
Receivers revoke the old pin with oacp trust revoke <kid> — per receiver, or --all-receivers for every receiver in the project that pins it, the compromise-response path. The revoke validates the kid’s canonical spelling up front, refuses a kid no receiver pins, keeps the key material in place on the revoked entry so the reader’s re-validation still applies, and re-emits the file under the project trust lock. A revoked pin refuses messages it has not seen — and import never brings it back to life.
Flipping to enforce
Section titled “Flipping to enforce”Enforce is live since v0.4.2, and the pre-flight discipline matters precisely because the flip has teeth. Before any receiver sets signing.verify_mode: enforce, run a fleet-wide re-pin sweep and then oacp doctor. Every receiver must pin every peer identity in the project catalog, and every active pin must refer to a cataloged identity — doctor reports one aggregate pin completeness result with counts in both directions (since v0.4.1). Gaps are warnings while the project stays in warn mode; the moment any receiver explicitly configures enforce, the same result becomes a blocking error, so a missing pin cannot turn into silent message rejection after the flip. Unlike the advisory catalog-not-pinned drift signal, pre-enforce completeness intentionally has no liveness exemption: every cataloged peer relationship must be ready before any profile enforces.
After the flip, rejection is mechanical: an unsigned or unverifiable message from any sender — pinned or not — quarantines to dead_letter/ at intake with an intake_rejected decision, and the non-gate read paths hold it as an unverified row rather than parsing it. Doctor reports per-receiver policy-file authorization status alongside pin completeness, so a fleet can confirm both halves — message trust and policy trust — before and after enforcing.
Conformance
Section titled “Conformance”The wire format is pinned by a byte-exact conformance corpus at tests/conformance/signing/ — golden fixtures for the trailer boundary, signed prefix bytes, and JWS preimages, plus a tamper-detection suite covering byte flips, trailer transplants, kid substitution, and whitespace/EOL edge cases. The receive-path contract is pinned separately at tests/conformance/intake/ (since v0.4.2) — four failure classes under each verify mode, plus a signed-verified positive control. Implementations of the framing or the verify flow should run against both; each corpus README defines which expected fields are normative.
Signing requires the optional crypto extra:
pip install 'oacp-cli[crypto]'oacp key gen --agent claudeCommand-level flags live in the CLI reference: oacp key, oacp trust, oacp verify.