BAP Content Assertion Profile 1

Copy Markdown View Source

Document status: normative contract for bap-content-assertion/1, revision 2 (erratum 2; erratum 3 amends revision 2 in place; see the errata registry); first released in package 0.7.0; the companion signer supports it from bounded_authority_report_adapter 0.9.0. It is a sibling attestation profile under governance § Byte-distinct sibling attestation profiles.

The key words MUST, MUST NOT, REQUIRED, SHOULD, and MAY are interpreted as described in BCP 14 only when they appear in all capitals.

1. Identity and purpose

A content assertion binds exact external content to an issuer, audience, subject lineage, semantic profile, time window, and predecessor. The profile identity is bap-content-assertion/1; its suite is BAP1-Ed25519-SHA256, using Ed25519 and protected alg: "EdDSA" (REQ-CA1-CORE-identity).

The separately named API is BoundedAuthorityProtocol.ContentAssertion.V1. Selection MUST be explicit, never inferred from bytes or retried after another profile fails. This profile and every existing contract-major, loopback proof, and role-attestation profile MUST reject one another's bytes (REQ-CA1-CORE-separation). No runtime registry, schema dispatch, negotiation, or fallback is introduced (REQ-CA1-CORE-no-dispatch). Verification is not authority.

2. Wire contract

The compact is the standard three-segment JWS. Its protected header contains exactly alg: "EdDSA", typ: "ba+content-assertion", and kid, a nonempty ASCII identifier under the existing BAP1 [A-Za-z0-9._~-] and kid_bytes rules. kid is a lookup hint, never a trust selector. Every header byte is signed (REQ-CA1-HEADER-closed).

The payload contains exactly thirteen required members:

MemberType and rule
vInteger exactly 1.
jtiNonempty StringOrUri artifact identifier.
issNonempty StringOrUri asserted issuer identifier.
audOne nonempty StringOrUri audience; never an array.
subNonempty StringOrUri stable assertion-lineage identifier.
profileNonempty StringOrUri semantic-profile identifier, compared as data.
profile_digestCanonical unpadded base64url of exactly 32 bytes.
content_digestCanonical unpadded base64url of exactly 32 bytes.
genInteger from 1 through caller integer_magnitude.
prevCanonical unpadded base64url of exactly 32 bytes.
iatInteger NumericDate within both signs of caller integer_magnitude.
nbfInteger NumericDate within both signs of caller integer_magnitude.
expInteger NumericDate within both signs of caller integer_magnitude.

All identifier strings obey the BAP1 StringOrUri rules and identifier_bytes. Every listed member is required; additional members, duplicate members, wrong types, and float lexemes for integers are invalid (REQ-CA1-CLAIM-closed). Digest widths are fixed; no algorithm tag or alternate encoding is accepted (REQ-CA1-CLAIM-digests).

Producer, parser, assembler, and verifier MUST enforce iat <= nbf < exp (REQ-CA1-CLAIM-time). gen == 1 if and only if prev is 32 zero bytes; other pairings are invalid (REQ-CA1-CLAIM-genesis). Both decoded header and payload bytes MUST equal their RFC 8785 canonical re-encoding (REQ-CA1-CLAIM-canonical).

The compact MUST fit anchor_bytes and applicable compact/segment/decoded-segment, JSON/JCS, structural, identifier, and numeric bounds. Callers may tighten ceilings, never widen them or change fixed widths. Producer-emitted bytes and projected compact length MUST satisfy the same caller bounds as consumption (REQ-CA1-BOUND-symmetry).

3. Digest contract

content_digest(bytes, bounds) accepts exactly 1 through content_bytes bytes. The new content_bytes maximum is 65,536 and may be tightened.

BoundMaximum
content_bytes65,536

The result is raw 32-byte SHA-256 of the following concatenation:

UTF8("BAP1-CONTENT") || 0x00 || exact_content_bytes

BAP MUST NOT parse, normalize, re-encode, or fetch that content (REQ-CA1-DIGEST-content). Content authors own serialization and schema validation. A caller MUST retain and compare the exact hashed bytes when interpreting content. profile_digest is an opaque caller-pinned 32-byte identity; BAP only compares it (REQ-CA1-DIGEST-profile).

assertion_digest(compact, bounds) first validates the compact as this profile, including closed shape, canonical encoding, time/genesis structure, and bounds, then returns unprefixed SHA-256 of its exact received bytes. It does not verify the signature or trust. The verified facts digest and successor prev use this same hash domain (REQ-CA1-DIGEST-assertion). Hashing a structurally valid compact does not turn it into verified facts.

4. Verification

verify_assertion(compact, %ExpectedContentAssertion{}) requires all these members:

Expected memberRequired value
attestorV1.HistoricalPublicKey with exact key ID, raw 32-byte public key, integral valid_from, and integral valid_before or :unbounded.
issuer, audience, subject, profileExpected nonempty bounded StringOrUri values.
profile_digest, content_digestExpected raw 32-byte values.
nowExplicit integral NumericDate within the caller's signed magnitude ceiling.
boundsValid bounds under the common tightenable-ceiling contract.

Malformed expected values MUST return the closed error before unsafe dereference; finite key-window endpoints obey integer magnitude and valid_from < valid_before (REQ-CA1-VERIFY-context). Verification MUST check:

  1. Profile parsing and canonical bytes from section 2.
  2. Header kid equals attestor.key_id (REQ-CA1-VERIFY-key).
  3. iss, aud, sub, and profile exactly equal their expected strings (REQ-CA1-VERIFY-identities).
  4. Both profile and content digests equal their expected raw bytes (REQ-CA1-VERIFY-digests). Content-digest comparison has no opt-out.
  5. iat >= valid_from, nbf >= valid_from, and exp <= valid_before when bounded. Equality at the finite expiry ceiling is valid (REQ-CA1-VERIFY-containment).
  6. nbf <= now < exp (REQ-CA1-VERIFY-now). No implicit clock or skew is used.
  7. The exact JWS signing input's Ed25519 signature verifies against the supplied public key (REQ-CA1-VERIFY-signature).

The signature check of item 7 MUST follow the strict Ed25519 rule of BAP v1 § 14.1 (REQ1-SIGNING-ed25519-canonical-point, REQ1-SIGNING-ed25519-small-order-reject, REQ1-SIGNING-ed25519-scalar-range, REQ1-SIGNING-ed25519-cofactorless), and the attestor public key MUST pass key acceptance (REQ1-KEY-ed25519-acceptable). Revision 2 adds this statement (erratum 2); the profile corpus and its verdicts are unchanged, and the cases live in the additive strict-Ed25519 corpus.

Structural iat <= nbf and the current-window check imply iat <= now. No key selection, trust walk, revocation query, content schema evaluation, replay reservation, or host decision is performed (REQ-CA1-VERIFY-pure).

Success returns %ContentAssertionFacts{} with exactly these value fields: version: 1, attestor_key_id, attestor_key_fingerprint, jti, iss, aud, sub, profile, profile_digest, content_digest, gen, prev, iat, nbf, exp, digest, verification: :signature_and_window, and trust: :not_evaluated. The fingerprint uses RFC 7638 Ed25519 JWK thumbprint bytes. Digests and fingerprint are raw 32-byte values. Inspection MUST redact values; facts contain no raw key, signature, decision, or authorization marker (REQ-CA1-VERIFY-facts).

5. Successor verification

verify_successor(predecessor_facts, successor_facts, bounds) accepts only complete ContentAssertionFacts structs with version 1, verification: :signature_and_window, and trust: :not_evaluated. It MUST validate their scalar shapes, identifier/numeric bounds, fixed byte widths, structural time/genesis rules, and markers. Decoded artifacts and malformed or partially populated facts MUST fail closed (REQ-CA1-SUCCESSOR-input).

It then requires:

  • Equal iss, aud, sub, profile, and profile_digest (REQ-CA1-SUCCESSOR-context).
  • successor.gen == predecessor.gen + 1, without overflowing permitted magnitude (REQ-CA1-SUCCESSOR-generation).
  • successor.prev equals predecessor.digest byte-for-byte (REQ-CA1-SUCCESSOR-predecessor).
  • successor.iat >= predecessor.iat (REQ-CA1-SUCCESSOR-time).
  • successor.jti != predecessor.jti (REQ-CA1-SUCCESSOR-identity).

It does not compare signing key ID/fingerprint, validity windows, or content digest; those may change in a successor. It does not require the predecessor to remain unexpired and receives no present-time input. Each new assertion must separately pass verification using its caller-admitted key and expected content. Reconstructed facts are caller-provenanced values, not opaque capabilities; this comparison cannot establish their original signature verification or persistence history (REQ-CA1-SUCCESSOR-provenance).

sub remains stable within a lineage. A consumer-selected transfer or re-enrollment that starts a new lineage uses a new subject and genesis; its business meaning is outside BAP. Bootstrap, missing-history admission, global latest generation, idempotency, fork resolution, and durable replay state remain host policy.

6. Public API and assembly

The profile namespace provides:

SurfaceSuccessful result
assertion_signing_input/2{:ok, V1.SigningInput.t()} with the profile's distinct kind.
assemble_compact/2, assemble_compact/3{:ok, compact_bytes} from an external raw 64-byte signature; /2 uses default bounds.
decode_assertion/2{:ok, DecodedContentAssertion.t()}; no signature or trust claim.
verify_assertion/2{:ok, ContentAssertionFacts.t()}.
content_digest/2, assertion_digest/2{:ok, raw_32_byte_digest}.
verify_successor/3:ok.

Every failure is exactly {:error, :invalid}; no values leak in errors (REQ-CA1-API-complete). All runtime functions are pure. No private key or signing callback enters this package (REQ-CA1-API-no-signer).

The producer %ContentAssertion{} carries attestor_key_id, jti, iss, aud, sub, profile, profile_digest, content_digest, gen, prev, iat, nbf, and exp; v is fixed by the profile. The decoded struct carries version: 1, attestor_key_id, the payload values corresponding to those producer members, and verification: :not_evaluated. It MUST NOT masquerade as verified facts (REQ-CA1-API-decoded).

Assembly MUST admit only this profile's signing-input kind and revalidate all header/payload structure and bounds, and refuse a signature that fails the keyless signature rule of BAP1 rule S1 (R canonical and not of small order, S < L; erratum 3), before returning bytes; it emits accepted signature bytes unchanged. It MUST NOT infer the profile from a failed generic assembler (REQ-CA1-API-assembly). Signature input is standard ASCII base64url(protected) || "." || base64url(payload).

7. Trust, knowledge, and operational limits

One successful verification proves equality and signature/window checks against explicit inputs. It does not prove content truth, semantic-profile validity, issuer trust, subject key possession, revocation freshness, latest generation, complete history, or permission to execute (REQ-CA1-SECURITY-not-authority).

Key rotation may use separately verified historical transitions, but all key trust and windows remain caller-supplied. This profile MUST NOT synthesize validity windows from transition times or implicitly admit a successor key. Existing transition verification's effective instant must lie inside both supplied key windows (REQ-CA1-SECURITY-trust).

An offline verifier cannot discover unseen revocations or successors. A restored snapshot can restore earlier clock/high-water/history state. Authentic bytes do not establish administrator-resistant restore continuity or distinguish copied content and keys. Such guarantees require separately qualified host mechanisms (REQ-CA1-SECURITY-knowledge).

8. Conformance and release

The profile corpus location is priv/conformance/attestation-profiles/content-assertion/v1. Revision 1 contains 131 assertion cases, 9 content-digest cases, and 14 successor cases. The frozen index SHA-256 is 14b7436ccf7cc91fece52a1578c3760df6720a93494d147ee5ab523e2ce21876. Every consumer MUST pin this exact index and verify its indexed file hashes (REQ-CA1-CONFORMANCE-pin). Corpus agreement is local source qualification; package publication and connected companion receipts remain separate requirements.

Required cases cover every missing/unknown member; wrong type/algorithm/version; duplicate and noncanonical encodings; every digest width/encoding; genesis pairings both ways; structural time and both key-window edges; expected-field mismatches; meaningful signature tampering; every successor rule and malformed facts; changed signing key with an otherwise valid successor; both sides of all relevant size and numeric limits; ES256 confusion; and bidirectional rejection with every existing profile. Producer/assembly bytes and digest output MUST agree independently. Named safety checks require red-capable mutation evidence (REQ-CA1-CONFORMANCE-cases).

All maintained independent SDKs, including the graduated TypeScript verifier, MUST implement and certify the surface before the first package release bearing it (REQ-CA1-CONFORMANCE-sdks). A real companion signer and an independent non-Elixir verifier MUST retain issuance/consumption receipts, including content-digest equality, against the exact immutable candidate (REQ-CA1-CONFORMANCE-receipts).

The first publication requires a new pre-1.0 minor, immutable source/package/corpus identities, completed package gates, and owner authorization for the exact release. Companion dependency admission follows its own existing policy (REQ-CA1-RELEASE-immutable).

9. IANA considerations

IANA is asked to register application/ba-content-assertion+jwt using the RFC 6838 field template under LIMITED USE. The ready-to-file source and rendered form live in docs/design/iana/media-types.json and docs/design/iana/media-types.md. Filing remains gated on the protocol's first external submission; this profile and its protected ba+content-assertion wire typ do not claim an existing registration (REQ-CA1-IANA-template).