Synced verbatim from
fornax-coreat build time — edit it there, not here.
ADR 0007: Signed policy bundles
Status: Accepted Date: 2026-09-03 Jira: FORNX-118 (child of FORNX-69)
Context
FORNX-116 (ADR-0006) defined policy as immutable, digested data
(PublishedPolicyRevision + PolicyBinding), but a digest alone proves
nothing about who published a revision — anything that can write bytes to
the local cache can forge a PolicyRevisionBody whose digest matches
itself. This ADR adds signature verification on top of that model: a
[SignedPolicyBundle] is the wire envelope fornax-cloud (out of scope
here) produces; fornax-core verifies it against a locally-trusted key set
before any revision or binding inside it is trusted.
Decision
Trust boundary
fornax-core — this crate and everything that depends on it — only ever
verifies. There is no signing key, no Signer implementation, and no
key-generation code in bundle.rs's non-test paths; verify_bundle is a
pure function over untrusted bytes plus a locally-supplied trust store.
How far this is mechanically enforced, and how far it is not (a real
deviation from this ticket's original design). The design that kicked off
this ticket assumed ed25519-dalek 3.0.0's signing capability (SigningKey,
the Signer impl) lived behind a Cargo feature named signature, so
depending on the crate with default-features = false in [dependencies]
and enabling features = ["signature"] only in [dev-dependencies] would
make "this crate cannot sign" a compile-time, dependency-level guarantee.
That assumption does not hold against the real crate: ed25519-dalek
does have an optional dependency literally named signature, but it gates
only the prehashed (Ed25519ph/digest) signing path, not the ordinary
Signer for SigningKey implementation — that impl uses ed25519::signature
re-exported through the always-required ed25519 crate, and is compiled in
unconditionally regardless of feature flags. Verified empirically: a
minimal crate depending on ed25519-dalek = { version = "3", default-features = false } with no dev-only feature at all still signs and
verifies successfully. There is no way to strip SigningKey/Signer out
of the dependency tree via Cargo features in this crate version.
Given that, the trust boundary here is enforced by code convention, not
the compiler: bundle.rs's non-test code never imports SigningKey or
Signer, and the only place either name appears in this crate is
policy/tests.rs, under #[cfg(test)], for fixture generation. This is a
weaker guarantee than the original design intended, and worth stating
plainly rather than implying a compiler-enforced boundary that does not
actually exist. fornax-types/Cargo.toml still declares
ed25519-dalek.workspace = true with default-features = false in
[dependencies] only (no separate [dev-dependencies] entry, since one
would have implied a distinction that provides no actual isolation) —
default-features = false still matters for skipping the fast/zeroize
features (performance and secret-zeroing conveniences irrelevant to a
verify-only crate that never generates or stores a private key).
D1: sign the transmitted bytes verbatim
The envelope's payload_b64 is the exact base64 the signature covers.
verify_bundle never re-serializes a parsed BundlePayload to reconstruct
signed bytes. fornax-cloud (Python, json.dumps) is the producer;
fornax-core (Rust, serde_json) is only ever the verifier. Re-serializing
before checking a signature risks two independent JSON serializers
producing different byte sequences for semantically identical data —
silently breaking verification of a legitimate bundle, or worse, silently
accepting a payload the signer never actually signed if the two
serializations happened to both parse to the same signature check target
through some other path. Signing and verifying the transmitted bytes
directly makes this divergence impossible by construction.
D2: verify-then-parse
The envelope (SignedPolicyBundle) is intentionally minimal: a schema
version, a base64 payload, and a signature list — no nested types, no
timestamps read before a signature has been checked. MAX_PAYLOAD_BYTES
(1 MiB) and MAX_SIGNATURES (8) bound the pre-authentication work an
attacker can force onto this path before any cryptographic check occurs.
BundlePayload — the authenticated content, including the full
PublishedPolicyRevision and its PolicyBindings — is parsed only after
a signature has verified successfully (evaluation order below).
D3: trust fails closed — inverting ADR-0006's fail-open selector rule
An unrecognized SignatureAlgorithm is always a rejection
(UnsupportedAlgorithm), for both the signature entry's own algorithm and
the trusted key's algorithm. This is a deliberate inversion of ADR-0006's
"an unrecognized selector value still matches" rule
(TargetSelector::matches's SelectorNotUnderstood path). The two rules
optimize for opposite failure directions on purpose:
- Policy application fails open (ADR-0006): an admin policy binding you can't fully parse should still apply rather than silently vanish — silently not enforcing a policy nobody knows failed to apply is the unsafe direction there.
- Trust decisions fail closed (this ADR): an algorithm this binary doesn't recognize must never be treated as an accepted signature — silently accepting an unrecognized algorithm as "probably fine" is the unsafe direction here. There is no data-integrity case for "apply anyway" when the question is literally "was this authenticated."
D4: rotation via pre-distribution + multi-signature, no self-updating trust root
A bundle may carry more than one signature (up to MAX_SIGNATURES); any
one signature that verifies against a currently-valid trusted key is
sufficient (threshold 1). Key rotation works by a publisher signing with
both the outgoing and incoming key during an overlap window, while
operators roll TrustedVerificationKeys forward at their own pace — a
verifier trusting only the old key, only the new key, or both, all accept
the same bundle during that window.
Explicit non-goal: no self-updating trust root, no fetch-and-refresh of
trusted keys over the network, no TUF-style delegation chain. The trust
root is static configuration only — a compiled-in default, an operator
file, or an environment override — resolved and loaded entirely locally,
consistent with ADR-0001 D2 (no cloud dependency on the local critical
path). TrustedVerificationKeys::load never performs I/O itself; it
parses and validates a string a caller already obtained.
D5: clock skew asymmetry
CLOCK_SKEW_TOLERANCE_SECONDS (300s) applies to not_before only: a
bundle is accepted up to 5 minutes before its stated not_before. It does
not apply to expires_at — expiry is exact, no grace period, at all.
This asymmetry is visible in the rejection vocabulary itself:
BundleNotYetValid carries a tolerance_seconds field; BundleExpired
does not, by construction, so a caller can't accidentally reason about a
grace period that isn't there. The rationale: undershooting not_before by
a few minutes of clock skew just delays when a legitimately-issued bundle
takes effect (harmless); overshooting expires_at by even a few minutes
means running expired policy past the point its issuer decided it should
stop applying, which especially matters for a bundle that loosens
restrictions and was deliberately given a short lifetime for that reason.
now: DateTime<Utc> is a parameter to verify_bundle, never Utc::now()
internally — the same discipline PolicyDraft::publish's published_at
parameter already established in ADR-0006, for the same reason:
deterministic, reproducible tests, and no hidden wall-clock dependency in a
security-relevant check.
D6: the inner revision digest is a liveness check, not a security control here — and a cross-repo canonicalization hazard
BundlePayload.revision is a full PublishedPolicyRevision, which
revalidates its own digest via its existing TryFrom<PolicyRevisionWire>
(ADR-0006) the moment serde_json::from_slice::<BundlePayload> runs.
verify_bundle additionally checks that every binding's
revision_ref.digest equals revision.digest()
(BindingRevisionMismatch). Both checks catch a bundle that is internally
inconsistent or was tampered with before signing (which would already
fail the outer signature check) or corrupted in a way that happens to
survive JSON parsing but not digest recomputation — they are a
belt-and-suspenders correctness/liveness check, not what makes this bundle
trustworthy. The signature is what makes it trustworthy; the digest check
only proves the payload the signer signed is internally self-consistent.
This matters because canonical_bytes (ADR-0006) is serde_json::to_vec
on the typed Rust struct, and its exact byte layout depends on collection
ordering that is a property of the Rust type definitions, not something
visible in the JSON schema alone:
PolicyContent::pinned_fields(aBTreeSet<PolicyFieldId>) sorts byPolicyFieldId's enum declaration order, not alphabetically.EnforcementRulelists sort byActionClass's enum declaration order (enforced at publish time byPolicyDraft::publish).SensorScope::required_signalssorts bySignalClass's wire-tag string (signal_class_sort_key), becauseSignalClasshas noOrd.CacheScope::max_age_seconds_by_risk(RiskClassSeconds) is a fixed-field struct, not a map, so its four fields serialize in declaration order, notRiskClass's own enum order (the two happen to coincide today; nothing enforces they must).
fornax-cloud's Python producer must reproduce every one of these
orderings exactly, or a revision it signs will digest-mismatch on the Rust
side despite being semantically identical. The frozen fixture
(tests/fixtures/signed_policy_bundle_v1.json) is the executable contract
for this — fornax-cloud's own test suite should assert it can reproduce
this exact fixture's canonical_bytes given equivalent input, not just
that its output "looks like" the schema.
Wire types
SignedPolicyBundle { bundle_schema_version: u32, payload_b64: String, signatures: Vec<BundleSignature> }
BundleSignature { key_id: KeyId, algorithm: SignatureAlgorithm, signature_b64: String }
BundlePayload { bundle_schema_version, bundle_id: Uuid, sequence: u64, issued_at, not_before,
expires_at: String, provenance: BundleProvenance,
revision: PublishedPolicyRevision, bindings: Vec<PolicyBinding> }
TrustedKey { key_id, algorithm, public_key_b64: String, not_before, not_after, comment }
TrustedVerificationKeys { schema_version: u32, keys: Vec<TrustedKey> }
VerifiedPolicyBundle -- private fields, accessors only; verify_bundle is the sole constructor
Base64 throughout is strict canonical: standard alphabet, required padding,
non-canonical trailing bits rejected — base64::engine::GeneralPurposeConfig
with DecodePaddingMode::RequireCanonical and
with_decode_allow_trailing_bits(false). An attacker-controlled string must
decode exactly one way or be rejected outright, never interpreted leniently.
The signed message is BUNDLE_SIGNING_DOMAIN (b"fornax-policy-bundle/v1\n")
concatenated with the raw decoded payload bytes — domain separation so a
signature produced for this purpose can never be replayed as a valid
signature over some unrelated message an issuer's key might also sign.
verify_bundle evaluation order (normative)
-
Parse envelope (
MalformedEnvelope). -
Check
bundle_schema_versionis supported (UnsupportedBundleSchemaVersion). -
Check
1..=MAX_SIGNATURESsignatures present (NoSignatures/TooManySignatures). -
Strict-decode
payload_b64, enforceMAX_PAYLOAD_BYTES(MalformedPayloadEncoding/PayloadTooLarge). -
For each signature, in order: look up
key_idin the trust store (skip to the next signature if absent); check the key's ownnot_before/not_afterwindow againstnow(never against the bundle's own unauthenticatedissued_at); check algorithm; decode signature bytes;verify_strict(never plainverify— rejects non-canonical point encodings and small-order keys) overBUNDLE_SIGNING_DOMAIN ‖ payload_bytes. The loop always runs to completion, or to the first success — it never returns on the first unusable signature. A known key_id that is out of its validity window, uses an unsupported algorithm, or has malformed key/signature material is recorded as a skip reason and the loop continues to the next signature; only the first such reason is kept, for a deterministic error later. This is what makes D4 rotation work: a bundle signed by both an outgoing key already past itsnot_afterand an incoming key still verifies via the incoming key, regardless of which signature appears first in the list — an early-return implementation would reportKeyRetiredon the first signature and never try the second, which would defeat rotation entirely.Once every signature has been tried, resolve in this exact precedence:
- some signature verified → success,
verified_byis thatkey_id. - else, if any trusted-and-current key's signature was checked and
failed →
SignatureInvalid(tampering — a trusted, currently-valid key whose signature simply doesn't cover these bytes — outranks a mere configuration problem found on a different signature entry). - else, if any known key_id was skipped for a window/algorithm/malformed
reason → that first-recorded reason (
KeyNotYetValid,KeyRetired,UnsupportedAlgorithm, orMalformedSignature). - else →
UnknownKeyId(no offeredkey_idwas ever present in the trust store at all).
- some signature verified → success,
-
Parse
payload_bytesintoBundlePayload(MalformedPayload— subsumes the inner revision's own digest-mismatch rejection via itsTryFrom, since that runs during this deserialization). -
Check
payload.bundle_schema_version == envelope's (SchemaVersionMismatch). -
Parse
not_before/expires_at/issued_atas RFC3339 (MalformedTimestamp). -
Window check with the D5 asymmetry (
BundleNotYetValid/BundleExpired). -
Every binding's
revision_ref.digestmust equal the revision's own digest (BindingRevisionMismatch). -
Construct
VerifiedPolicyBundle.
Residual risks (named, deferred to FORNX-119)
- Rollback. A validly-signed, unexpired old bundle can revert policy
to a weaker state.
BundlePayload.sequenceexists in the wire schema specifically so a future check can rejectsequence <= last_known_good.sequence— butverify_bundledoes not check it. Doing so requires a last-known-good comparison point this ticket has no persistence layer for; that is FORNX-119's job (the same ticket that ownsCacheScopeactivation per ADR-0006). - Binding-set omission. A bundle names exactly one revision. A transport-level attacker (not a forger — they cannot produce a new valid signature) can withhold a more-restrictive bundle while replaying an older, more-permissive one that is still unexpired and still validly signed. Detecting withholding (as opposed to detecting a forged or stale bundle) needs the same last-known-good/activation machinery as risk 1 and is equally out of scope here — also FORNX-119.
- No revocation. ADR-0006 already notes revocation is a separate
record referencing a digest, out of scope for that ticket. The only
lever available here is key retirement via a
TrustedKey'snot_after— there is no mechanism to revoke one specific bundle while its signing key remains otherwise valid.
Non-goals (explicit for this ticket)
- No wiring into
fornax-store, the daemon, or any CLI command — this ticket defines and tests the verification boundary infornax-typesonly; a later ticket is responsible for a real caller ever invokingverify_bundleagainst a network-fetched or file-loaded bundle. - No signing capability of any kind lives in this repository outside
#[cfg(test)]fixture generation, per the trust-boundary discussion above. - No
sequence/last-known-good rollback defense, no binding-withholding detection (residual risks 1–2) — FORNX-119. - No revocation beyond key retirement (residual risk 3).
- No self-updating trust root / TUF-style delegation (D4).
Test coverage
crates/fornax-types/src/policy/tests.rs, T28 onward (continuing FORNX-116's
T1–T27): valid-signature acceptance; tamper detection distinguishing
SignatureInvalid (trusted key, bad signature) from UnknownKeyId (no
trusted key offered); the full clock-skew asymmetry (BundleNotYetValid
within/beyond tolerance, BundleExpired with no grace at all); rotation
where a signature list carries an already-retired key alongside a still-valid
one (in both orderings), proving the per-signature loop never returns early
on the first unusable signature; rotation
across old-key-only/new-key-only/both-keys trust stores; KeyNotYetValid/
KeyRetired from a trusted key's own validity window; the fail-closed
UnsupportedAlgorithm inversion of ADR-0006's fail-open selector rule;
strict base64 rejection (non-canonical characters, missing padding,
nonzero trailing bits); MalformedPayload for non-JSON bytes and a
hand-edited inner digest; signature-count and payload-size bounds;
schema-version cross-check; binding/revision digest mismatch; the frozen
fixture (tests/fixtures/signed_policy_bundle_v1.json) both verifying and
being exactly reproduced by its #[ignore]d regenerator; a dedicated
domain-separation test (a signature over the bare payload, without
BUNDLE_SIGNING_DOMAIN, must be rejected); a single-byte-mutation property
test over a valid envelope (every mutation errs, none panics); and
TrustedVerificationKeys::load's own validation (duplicate key ids with
differing material, an empty key set, malformed key bytes).