Skip to content

feat(attestation): signed key documents, so host write access is not root compromise - #119

Open
rrrodzilla wants to merge 1 commit into
mainfrom
feat/116-signed-key-documents
Open

rrrodzilla wants to merge 1 commit into
mainfrom
feat/116-signed-key-documents

Conversation

@rrrodzilla

Copy link
Copy Markdown
Contributor

Closes #116.

What this changes

Specification 0.9.0

  • §7.2 gains an OPTIONAL signed form of the key document, served at the same endpoint: the response format inside a signed member carrying two new REQUIRED fields (version, monotonic per publication, and expires, RFC 3339), plus a signatures array of detached Ed25519 signatures. The signature covers "agent-uri-key-document-v1\n" followed by the exact served octets of signed, so there is no JSON canonicalization anywhere: signed as serialized, verified as received.
  • The signing key is a root key held offline and pinned by verifiers out of band. It must not appear in the document's own keys; a document that vouches for itself vouches for nothing.
  • Verifier obligations are stated for both postures. Pinned: require the signed form, verify one pinned signature, refuse an expired document, refuse a version behind the newest accepted. Unpinned: either form works on the terms of the bare one, which is the migration path.
  • §8.12 names publication-host compromise as its own threat: why none of §8.2's mitigations reach it (the revocation channel is the compromised channel), why §8.3's mitigation is exactly what the attack satisfies, and what the signed form degrades the attack to (denial of service, which fails closed, and replay bounded by signature, expires, and the version floor).
  • Appendix C.3 records the reference bounds: 65536-byte body, 16 keys, 8 signatures.

agent-uri-attestation 0.8.0 → 0.9.0

signed_document module: SignedKeyDocument (parse, then verify/verify_at against pinned keys), KeyDocumentPayload (flattens over KeyDocument, so trust_store()/denylist()/check_belongs_to() are reused unchanged), ServedDocument to tell the two forms apart, and DocumentSigner/to_signed_json for the publishing side, which signs the exact octets it embeds. The payload's raw octets are kept via serde_json::RawValue; a reformatted document is a different document and the tests say so. root_kid is advisory and never selects a key. A malformed signatures entry is skipped, not fatal, and reported only if nothing verified. New errors DocumentExpired and DocumentUnsigned (the enum was already #[non_exhaustive]). The MAX_KEYS cap is enforced on both parse routes.

agent-uri-attestation-wellknown 0.2.0 → 0.3.0

KeyDiscovery::pin_root(root, PinnedRootKeys). For a pinned root: the bare form is refused (a fallback an attacker can trigger by deleting a signature is not a defence), the signature must verify, an expired document is refused, and a version behind the newest accepted is refused — the floor outlives the cache TTL and forget(), with forget_publication_history() as the deliberate reset. A cached signed document also stops being served at its own expires, whichever of that and the TTL comes first. Unpinned roots accept either form, with the signed payload's unauthenticated expires/version deliberately not enforced. New errors Unsigned, VersionRegression, VersionUnavailable (a rollback check that cannot run refuses rather than skips), InvalidRootKey. The whole policy lives in a network-free accept() seam and is tested against bytes.

Verification

  • cargo fmt --check, cargo clippy --workspace --all-features --all-targets: clean, zero warnings
  • cargo nextest run: 1127 passed, 0 failed
  • doctests: 45 passed across both crates
  • new fuzz target parse_key_document (both forms in the seed corpus), wired into the CI fuzz matrix and task fuzz; 1.3M execs clean in a local smoke run
  • MSRV build, cargo deny, cargo audit: clean

Not in this PR

No agent-uri-cli support for minting or inspecting signed documents. If operators are expected to run the offline key ceremony by hand, that deserves its own issue.

…7.2 (#116)

An unsigned key document makes write access to the publication host
equivalent to possession of the trust root's signing key: every channel
check authenticates where the bytes came from, and a host compromise
serves from the right place. Specification 8.12 now names that threat,
and 7.2 gains an OPTIONAL signed form against it, modeled on TUF's root
role: the response format wrapped in a signature by an offline root key,
carrying a monotonic version and an expires, signed over the received
octets behind a domain separator rather than over any canonicalization.

agent-uri-attestation 0.9.0 reads, verifies, and mints the signed form
(SignedKeyDocument, KeyDocumentPayload, DocumentSigner, ServedDocument);
agent-uri-attestation-wellknown 0.3.0 turns it into policy: pin_root
requires the signed form from that root, refuses a version behind the
newest accepted (a floor that outlives the cache and forget()), and
stops serving a cached document at its own expires. Roots nobody pins
keep working in either form, which is the migration path.

The parse_key_document fuzz target covers both forms in the corpus, CI
matrix, and Taskfile.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: the well-known key document is unsigned, so web-host write access equals trust-root compromise

1 participant