| Internet-Draft | attest Purchase Receipts | August 2026 |
| Martinalli | Expires 7 February 2027 | [Page] |
This document specifies attest, a signed digital purchase-receipt envelope that a buyer holds and that any party can verify offline, without contacting the issuer or any third-party service. It defines the receipt envelope and payload format, a restricted JSON canonicalization profile ("attest-JCS", built on RFC 8785), a pinned Ed25519 signature ruleset, an optional hybrid Ed25519+ML-DSA-65 post-quantum-resistant signature profile, issuer key and artifact manifests with rotation and compromise handling, a layered verification algorithm, and revocation-record semantics. This document is a snapshot profile: it distills, and never supersedes, the living attest specification maintained in the attest source repository. It normatively specifies exactly the core receipt format and the hybrid signature profile; the living specification's transparency-log, anchoring, and issuer-mediated transfer material is summarized only as non-normative pointers in Section 12 of this document.¶
This Internet-Draft is submitted in full conformance with the provisions of BCP 78 and BCP 79.¶
Internet-Drafts are working documents of the Internet Engineering Task Force (IETF). Note that other groups may also distribute working documents as Internet-Drafts. The list of current Internet-Drafts is at https://datatracker.ietf.org/drafts/current/.¶
Internet-Drafts are draft documents valid for a maximum of six months and may be updated, replaced, or obsoleted by other documents at any time. It is inappropriate to use Internet-Drafts as reference material or to cite them other than as "work in progress."¶
This Internet-Draft will expire on 7 February 2027.¶
Copyright (c) 2026 IETF Trust and the persons identified as the document authors. All rights reserved.¶
This document is subject to BCP 78 and the IETF Trust's Legal Provisions Relating to IETF Documents (https://trustee.ietf.org/license-info) in effect on the date of publication of this document. Please review these documents carefully, as they describe your rights and restrictions with respect to this document.¶
A digital purchase today is ordinarily a revocable license living inside one company's platform, not a thing the buyer holds. When the platform shuts down, delists a title, or changes terms, the only record that a purchase ever happened typically lived in the seller's own database and disappears with it. Existing consumer-protection efforts — disclosure requirements that a digital "purchase" is actually a license, and remedies for non-conforming digital content — address access and disclosure, but a legal remedy without evidence that a purchase occurred is difficult to exercise once the seller's records are gone.¶
attest defines an evidence layer for exactly that gap: a signed receipt envelope the store issues once, at the point of purchase, that the buyer keeps like a paper receipt and that anyone — the buyer, a marketplace, a friend, a preservation project — can verify offline, forever, against the issuer's own published key material. attest deliberately does not keep content alive, strip DRM, host or index content, process payment, or define a resale/transfer protocol on its own account (transfer is addressed only by the non-normative pointer in Section 12); it is only the evidence layer that lets whatever rights a buyer has survive the seller's disappearance.¶
Every implementation's conformance to the material this document
specifies is exercised by the cross-language conformance vector
corpus at docs/spec/vectors/ in the attest source repository
— specifically the vector groups covering the v0.1 baseline and the
v0.2 hybrid profile (the living specification's own Section 15 and
Section 6, respectively). docs/conformance.md in that same
repository documents the public, adapter-based process by which any
independent implementation, in any language, can run that corpus
against its own code and self-certify conformance; that process, not
this document, is the operative conformance procedure.¶
attest's normative source of truth is the living specification
maintained at docs/spec/attest-v0.1.md and
docs/spec/attest-v0.2.md in the attest source repository
([ATTEST-REPO]), together with the JSON Schema and
the cross-language conformance vector corpus that repository also
carries. This document is a snapshot profile of that living
specification's core receipt format and hybrid signature profile:
it distills normative text from the living specification into
Internet-Draft form, but the living specification remains
authoritative, and a conflict between this document and the living
specification is resolved in the living specification's favor.
Within this document specifically, a payload MUST
satisfy the structural constraints of the core receipt schema:
those constraints are precisely the ones expressed by the
revision-pinned attest receipt JSON Schema
([ATTEST-SCHEMA], attest-receipt.schema.json
pinned at tag v0.4.0, the mirrored v0.1 revision 5 / v0.2
revision 6 state), EXCEPT its Stage 3 (v0.2 §17.8) holder-binding
conditional on license.transferable and
buyer.pubkey, which belongs to the Stage 3 extension
(Section 12) and is outside this profile's
normative surface. This draft's own payload field registry
(Section 3.1) is a descriptive, prose
summary of that same structure, kept in step with it, never a
second and independent normative source: the schema itself,
minus that one named Stage 3 conditional, is the precise
structural authority a conforming implementation validates
against (Section 7 Step 5).¶
This document mirrors attest-v0.1.md at revision 5.¶
This document mirrors attest-v0.2.md at revision 6, for the complete core/hybrid surface it normatively incorporates: Section 2 (the hybrid Ed25519+ML-DSA-65 signature profile itself), Section 3 and Section 3.1 (the hybrid verification algorithm substituted for verification steps 1 and 4, and its pinned error-literal table), Section 4 (manifest continuity and trust for a hybrid signer), Section 6.2 (the structural ceilings that bind hybrid envelopes and manifests exactly as they bind v0.1's), and Section 13 and Section 13.1 (the hybrid AND-rule extended to revocation records and artifact manifests, and the mixed-keyset prohibition and migration ceremony); the same document's transparency, anchoring, and issuer-mediated transfer material is summarized only as a non-normative pointer in Section 12 of this document and remains normative solely in the living specification, never here.¶
A reader checking this snapshot for drift compares the two revision integers declared above against the "Revision log" section at the end of each named file in the living repository; because those logs only grow, a later revision existing there when this document is read is expected, and is not by itself a defect in this document — it means the living specification has moved on and this document has not yet been updated to match.¶
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.¶
This document uses three actor names, taken unchanged from the living specification:¶
Two terminology defusals are stated here explicitly, because each collides with a term a reader may already associate with a different IETF architecture:¶
A "receipt" in this document is a signed purchase-evidence
statement — the payload-plus-signatures envelope of
Section 3 — held and presented by the buyer who
purchased it. It is not a SCITT transparency-service
receipt in the sense of [RFC9943], which is a proof
that a signed statement was registered in an append-only log. This
document defines no transparency-log registration mechanism at all;
the living specification's optional, non-normative-here transparency
layer is pointed to, but not specified, in
Section 12.¶
"attest" is this project's name, chosen with no relationship intended to the IETF Remote Attestation Procedures (RATS) architecture [RFC9334]. This document makes no RATS claim of any kind: it defines no Attester, no RATS-sense Verifier, no Relying Party role mapping, no Evidence or Attestation Results semantics, and no claim about the integrity or trustworthiness of any execution environment. A reader arriving from a RATS background should treat the project name as a false cognate.¶
A receipt is transmitted as a JSON object with exactly three
top-level members: payload (the only signed content, see
Section 3.1), signatures (an array; see
Section 5 for its required shape under each
signature profile), and an OPTIONAL
delivery member.¶
delivery is unsigned: it is outside
payload and is not covered by any signature. When present, it
MAY carry delivery.salt (the base64url, unpadded, encoding of
the 16 raw bytes used as the buyer-commitment salt, see
Section 3.2) and delivery.issuer_manifest
(a key-manifest object, see Section 6, usable
directly as a trust-store entry). An envelope carrying
delivery.salt is a private artifact: implementations
MUST strip it before treating an envelope as generally
shareable. Tampering with delivery can neither forge nor
invalidate a receipt, because it is not itself signed; the salt it
may carry only has meaning insofar as it reproduces the signed
buyer.commitment, and any embedded manifest snapshot is
independently signature-checked on its own terms.¶
payload is the sole signed object. Every object below
permits additional, unlisted properties; an unrecognized top-level
payload field is signed and valid, reported only as a
warning (Section 7), never as a schema error — this
is the format's forward-compatibility mechanism.¶
| Field | Required | Semantics |
|---|---|---|
attest_version
|
REQUIRED | Const
"0.1" or "0.2"; fixes the payload shape and
signature profile for this receipt (see
Section 5). |
receipt_id
|
REQUIRED | A ULID
(Crockford base32, 26 characters, leading character
0-7). |
issued_at
|
REQUIRED | UTC
timestamp; anchors key-validity and
refund_window revocation checks. |
supersedes
|
schema-optional | ULID
or null; informational lineage pointer to a prior
receipt_id. Never an implicit revocation of the
superseded receipt. |
issuer
|
REQUIRED | Object; see below. |
buyer
|
REQUIRED | Object; see below. |
work
|
REQUIRED | Object; see below. |
license
|
REQUIRED | Object; see below. |
survivability
|
REQUIRED | Object; see below. |
| Field | Required | Semantics |
|---|---|---|
issuer.id
|
REQUIRED | Lowercase DNS domain (two or more labels); roots key discovery and issuer-binding (Section 7). |
issuer.display_name
|
REQUIRED | Human-readable name; carries no cryptographic weight. |
| Field | Required | Semantics |
|---|---|---|
buyer.commitment
|
REQUIRED | base64url, 32 decoded bytes; a scrypt commitment over a normalized identifier (Section 3.2). |
buyer.identifier_type
|
REQUIRED | Enum issuer-account | email. |
buyer.pubkey
|
OPTIONAL | base64url, 32 decoded bytes, or null; Ed25519 public key for the challenge-response binding path. |
| Field | Required | Semantics |
|---|---|---|
work.title
|
REQUIRED | |
work.publisher
|
REQUIRED | Names the publisher of record. |
work.edition
|
OPTIONAL | |
work.identifiers
|
REQUIRED | Object, one or more string-valued properties (issuer-scoped identifiers). |
work.artifact_series
|
conditionally required | Issuer-scoped series identifier; the current
artifact set for a series is resolved from issuer-signed
artifact manifests (Section 6), never from the
immutable receipt. When license.revocability == "none",
at least one of this field (non-empty) or
work.artifacts (non-empty array) MUST be present — see
the revocability row below. |
work.artifacts
|
conditionally required | Array of {role, platform, filename,
size_bytes, sha256} objects; an at-purchase snapshot, not a
live index. Same revocability: "none" conditional as
work.artifact_series, above. |
| Field | Required | Semantics |
|---|---|---|
grant
|
REQUIRED | Enum
perpetual | subscription. |
revocability
|
REQUIRED | Enum
none | refund_window | policy; governs
revocation-record effectiveness (Section 8).
When revocability == "none", the schema additionally
REQUIRES drm == "drm-free",
redownload_right == true under
survivability, and at least
one of a non-empty work.artifact_series or a non-empty
work.artifacts array; a revocability: "none"
payload violating any of these is schema-invalid, never merely
a warning. |
revocation_window_days
|
conditionally required | REQUIRED iff revocability ==
"refund_window". Integer, 1 to 3650; the window is
anchored to issued_at and evaluated against a
revocation record's own signed time, never the verifier's
clock. |
transferable
|
REQUIRED | Boolean. This document defines no meaning for this field: it is reserved, exactly as in the living specification's own core profile. Meaning is assigned to it only by the living specification's Stage 3 transfer profile, which is out of this document's normative scope (Section 12). |
not_transferable_before
|
OPTIONAL | ISO-8601 UTC string. Reserved in the same sense as
transferable; carries no meaning under the material
this document specifies alone. |
drm
|
REQUIRED | Enum
drm-free | drm-bound; a
drm-bound receipt otherwise verifies, with a mandatory
warning — except under revocability: "none" (above),
where drm-bound is schema-invalid, not merely
warned. |
terms_uri
|
REQUIRED | URI; annotation-only in schema terms, see Section 4. |
legal_text_sha256
|
REQUIRED | Lowercase hex SHA-256 of the license text at
terms_uri, hash-binding it into the signed
payload. |
jurisdiction_flags
|
OPTIONAL | Object, boolean-valued, open vocabulary. |
| Field | Required | Semantics |
|---|---|---|
redownload_right
|
REQUIRED | Boolean. |
mirror_policy_uri
|
OPTIONAL | |
mirror_policy_sha256
|
OPTIONAL | Hash-binds the mirror policy text into the signed payload. |
end_of_life
|
REQUIRED | Non-empty string, open versioned vocabulary; an unrecognized value is valid, with warning. |
eol_commitment_uri
|
OPTIONAL | String or null. |
eol_commitment_sha256
|
OPTIONAL | Hash-binds a future end-of-life commitment document. |
Two layered mechanisms bind a receipt to its buyer without signing a plaintext identifier.¶
Commitment (always present). With domain label the ASCII
string "Attest-buyer-commitment-v1":¶
P = UTF8("Attest-buyer-commitment-v1") || 0x00
|| UTF8(identifier_type) || 0x00 || UTF8(normalize(identifier))
commitment = scrypt(P, salt, N=32768, r=8, p=1, dkLen=32)
¶
salt MUST be exactly 16 raw bytes,
generated per receipt by the issuer, hashed as raw bytes (never as
base64url text). The scrypt parameters above are fixed by this
profile and MUST NOT be configurable per issuer.
normalize() is: for identifier_type == "email",
strip ASCII whitespace from both ends, apply Unicode NFC
normalization, then lowercase ASCII A-Z only
(byte-deterministic, no locale case-folding); for
identifier_type == "issuer-account", apply Unicode NFC
normalization only. In both cases the normalized string
MUST NOT contain the byte 0x00.¶
Revealing (identifier, salt) to a verifier is a
replayable bearer proof that also hands over the identifier itself;
per-receipt salts confine this to one receipt's commitment path. A
verifier MUST treat a disclosed identifier as
personal data not to be retained beyond the verification.¶
Key binding (buyer.pubkey, optional). The
stronger path: an Ed25519 public key bound into the signed payload,
proven via non-replayable challenge-response, with domain label
the ASCII string "Attest-binding-challenge-v1":¶
verifier sends nonce (>= 16 random bytes,
freshly generated per challenge)
buyer signs: UTF8("Attest-binding-challenge-v1") || 0x00
|| receipt_id || 0x00 || nonce
¶
The nonce MUST be at least 16 bytes and
MUST be freshly generated per challenge: without
that freshness requirement, a captured transcript could be
replayed and the challenge-response would not be the
non-replayable proof this binding path depends on.
receipt_id here is the receipt's own
payload.receipt_id, encoded as UTF-8 text, not decoded and
re-encoded. A verifier MUST NOT treat
buyer.pubkey equality across two receipts as proof of buyer
identity; keys SHOULD be per-receipt.¶
The signature input for a receipt is exactly
JCS(payload) — the output of the attest-JCS profile below —
encoded as UTF-8 bytes. attest-JCS follows
[RFC8785] (JSON Canonicalization Scheme) with one
deliberate deviation by restriction: full JCS permits any I-JSON
number, canonicalized via a rounding algorithm every implementation
must reproduce identically to stay interoperable. attest-JCS removes
that cross-language interoperability risk entirely by restricting
numbers to integers only, with magnitude strictly less than
253. A conforming canonicalizer MUST accept
a JSON number if and only if it is such an integer, and
MUST reject any float, any
NaN/Infinity/-Infinity construct, and any
integer at or beyond that magnitude. This is a restriction of, never
an incompatible extension to, [RFC8785]: every
attest-JCS output is also a valid JCS output.¶
Additional parse-time requirements, applied before any signature
or schema step: the input MUST be valid UTF-8; a JSON
object containing a duplicate member name MUST be
rejected outright, never silently deduplicated; object keys
MUST be serialized in the order produced by sorting
their UTF-16BE code-unit sequences; and lone UTF-16 surrogates,
whether arriving as literal bytes or as \uXXXX escapes,
MUST be rejected. An over-magnitude integer is
rejected at this canonicalization stage, before schema validation
ever runs: the corresponding verification result reports
signature: "invalid" and schema: "not_checked"
(Section 7), never a schema-validation failure.¶
Encodings: signatures, commitments, salts, and public keys
MUST be base64url without padding
([RFC4648] Section 5); SHA-256 hashes
MUST be lowercase hexadecimal; receipt_id and
supersedes are ULIDs (Crockford base32, 26 characters).
format: "uri" fields in the JSON Schema are annotation-only:
a conforming validator is not required to assert URI well-formedness
as a failure; integrity of a referenced document is guaranteed by its
accompanying SHA-256 hash binding, never by URI syntax.¶
signatures MUST be a JSON array with
exactly one entry, carrying kid (string), sig
(base64url, 64 decoded bytes), and alg equal to the literal
string "Ed25519". A verifier MUST reject any
other alg value, and MUST NOT use
alg to select a verification primitive: the algorithm for
attest_version: "0.1" is fixed by this profile, never by
dispatching on the signature block.¶
Verification uses [RFC8032] Edwards-Curve Digital
Signature Algorithm (EdDSA) under a pinned ruleset. A conforming
verifier MUST perform cofactorless (strict)
[RFC8032] verification and additionally
MUST: reject a signature whose scalar S is
non-canonical (S >= L, the Ed25519 group order — the
SUF-CMA property); and reject small-order or non-canonical
encodings of the public key and of the signature's R
component (the SBS property). This pinned ruleset exists so that
implementations built on different cryptographic backends disagree
loudly, at conformance-test time, rather than silently accepting a
malleable signature in the field.¶
A receipt hash, SHA-256(JCS(payload)), is defined for
bundle and deduplication purposes; it MUST NOT be
computed over the whole envelope, which contains the unsigned,
malleable delivery member. This hash is distinct from — and
MUST NOT be confused with — the transparency-log
commitment the living specification's Stage 2 profile defines; that
construction is out of this document's normative scope
(Section 12).¶
The hybrid profile pairs the classical Ed25519 primitive with ML-DSA-65 [FIPS204], a NIST security category 3 module-lattice signature scheme, so that forging a receipt requires breaking both primitives: an attacker who breaks only Ed25519 (for example, via a future cryptographically-relevant quantum computer) or only ML-DSA-65 (for example, via a classical cryptanalytic advance) still cannot forge a signature.¶
A hybrid envelope has the same three-member shape as
Section 3; the differences are inside
payload.attest_version (which MUST equal the
literal string "0.2") and signatures, which
MUST be a JSON array containing exactly two entries,
in this fixed order: entry 0 with alg == "Ed25519", entry 1
with alg == "ML-DSA-65". A verifier MUST
reject any other order, count, or alg value. Both entries
MUST carry the same kid — the hybrid pair is
one signer, not two independently resolved keys — and both
signatures MUST be computed over the identical
JCS(payload) canonical bytes: one signature input, signed
twice with two different keys.¶
Composite key binding lives in the manifest. Because
kid itself carries no algorithm information, the binding
between a hybrid signer's two public keys is established entirely
by the key manifest (Section 6): a key-entry object
carrying pub_ml_dsa_65 (base64url, 1952 decoded bytes) is
what makes that entry hybrid, and a verifier MUST NOT
accept a hybrid signature against an entry lacking it. A manifest's
own manifest_signature gains an optional
sig_ml_dsa_65 member (base64url, 3309 decoded bytes),
REQUIRED if and only if the signing key's own entry
is hybrid. This is AND-verified, fail-closed in both directions: a
hybrid signer's manifest signature missing its ML-DSA-65 leg
MUST be treated as invalid (a downgrade attempt), and
an Ed25519-only signer's manifest signature carrying a stray
ML-DSA-65 leg MUST likewise be treated as invalid.
The same AND-rule extends, unchanged in shape, to revocation records
(Section 8) and artifact manifests
(Section 6): a hybrid-keyed issuer's side-document
carrying only an Ed25519 signature is never honored.¶
Mixed-keyset prohibition. An issuer that declares the
hybrid profile MUST NOT hold an Ed25519-only key in
state active: doing so would silently downgrade the
issuer's claimed hybrid protection back to classical-only for any
receipt an attacker chooses to forge under the still-active
classical-only sibling. Migration from an Ed25519-only key to a
hybrid key MUST retire (or otherwise move out of
active) every Ed25519-only key in the same manifest-version
step that introduces the hybrid key; there is no sanctioned
intermediate coexistence state. A verifier resolving a manifest
exhibiting the mixed-keyset condition for a hybrid receipt it is
verifying MUST emit the exact warning literal
mixed_keyset_active_ed_only_sibling.¶
Verification substitutes an eleven-step hybrid check for steps
1 and 4 of Section 7 whenever
payload.attest_version == "0.2": signature-count, block
shape, algorithm-and-order, shared-kid, and type checks on
the signature block; the issuer-binding and key-status checks of
Section 7, unchanged; a check that the resolved key
entry carries pub_ml_dsa_65; then independent verification
of the Ed25519 leg and the ML-DSA-65 leg. This is AND semantics:
both legs MUST independently verify, or the receipt
is invalid. Only if both legs verify does verification continue to
the schema, revocation, and binding steps of
Section 7, unchanged. The result vocabulary and the
definition of ok (Section 7) are unchanged by
this profile: it introduces no new result value, only new ways to
arrive at signature: "invalid". A conforming
implementation SHOULD surface the nine pinned
hybrid error literals of the living specification's v0.2 §3.1
verbatim (or a superset containing them), so that
cross-implementation conformance testing can match on literal
text; Table 7 transcribes that table
verbatim.¶
| Literal (verbatim) | Emitted when |
|---|---|
hybrid envelope requires exactly two
signatures
|
signatures length ≠
2. |
malformed signature block
|
either signature entry is not an object. |
hybrid envelope requires algs Ed25519 and
ML-DSA-65 in order
|
entry 0/1 alg is not
exactly ["Ed25519", "ML-DSA-65"] in that order
(includes a duplicated alg). |
hybrid envelope signatures must share a single
kid
|
the two entries' kid values
differ. |
malformed signature block: 'kid' must be a
string
|
the shared kid is not a
string. |
malformed signature block: 'sig' must be a
string
|
either signature entry's sig is
not a string. |
key entry for kid {kid!r} has no ML-DSA-65
public key
|
the resolved manifest key entry
lacks pub_ml_dsa_65. |
signature verification failed
|
the Ed25519 leg fails to verify (unchanged literal from v0.1). |
ML-DSA-65 signature verification
failed
|
the ML-DSA-65 leg fails to verify. |
An issuer's identity is its DNS domain. An issuer SHOULD publish
its key manifest at https://<issuer.id>/.well-known/attest.json.¶
| Field | Required | Semantics |
|---|---|---|
issuer
|
REQUIRED | DNS domain;
MUST equal the domain prefix of every listed
kid. |
manifest_version
|
REQUIRED | Integer, monotonically increasing per issuer; rotation
continuity keys off N -> N+1. |
issued_at
|
REQUIRED | |
keys
|
REQUIRED | Array of key-entry objects; see below. |
manifest_signature
|
REQUIRED | Ed25519 signature (plus, for a hybrid signer, an
sig_ml_dsa_65 member, Section 5.2)
over JCS(manifest) with this member removed. |
| Field | Required | Semantics |
|---|---|---|
kid
|
REQUIRED | String,
<issuer-domain>/keys/<label>#<name>;
domain prefix MUST equal issuer. |
pub
|
REQUIRED | base64url, 32 decoded bytes; Ed25519 public key. |
pub_ml_dsa_65
|
required for a hybrid signer's entry | base64url, 1952 decoded bytes, Section 5.2. |
valid_from
|
REQUIRED | |
valid_to
|
OPTIONAL | Absent or null means open-ended. |
status
|
REQUIRED | Enum
active | retired | compromised. |
Rotation continuity is normative, not best-effort. A
manifest at manifest_version N+1 is auto-trusted only if it
was signed by a key that was active in the version-N manifest
already trusted; version gaps are bridgeable only by validating every
intermediate manifest. On a discontinuous rotation, or on conflicting
manifests for the same issuer, a verifier MUST report
trust: "unverified_rotation" and MUST NOT
auto-accept the manifest. For a hybrid signer, this continuity check
is enforced through the hybrid manifest signature
(Section 5.2): a rotation manifest whose signer
key is hybrid but whose manifest signature has been downgraded to
Ed25519-only fails the AND-verified check and is therefore not validly
signed for continuity purposes, even though the receipt's own hybrid
signature may independently verify cleanly against the manifest in
use.¶
Key compromise fails closed. A key marked
compromised invalidates all signatures ever made with it,
regardless of issued_at, because issued_at lives
inside the signed payload and is controlled by whoever holds the key.
A verifier MUST reject any receipt signature resolving
to a compromised key unconditionally. The same fail-closed
rule governs revocation records (Section 8): one
signed by a key that is not status == "active" in its
resolving manifest MUST be ignored, with a
warning.¶
Artifact manifests are separate signed side-documents, same
signing discipline as key manifests, carrying issuer,
series, version, an optional (required on manifests
produced after this profile's currency amendment)
manifest_version, released_at, the current
artifacts array (Section 3.1 shape),
and a manifest_signature.¶
Acceptance is conditional on authentication, not on being
merely issuer-signed. A verifier MUST treat an
artifact manifest as authenticated only if: its resolving key
manifest is itself self-consistent; the manifest's signing key
resolves, in that key manifest, to a key-entry with
status == "active"; released_at falls within that
key's validity window; the artifact manifest's own issuer
field equals the resolving key manifest's issuer field; and
the signature verifies (for a hybrid signer, under the AND-rule of
Section 5.2). An unauthenticated artifact
manifest contributes nothing to currency and MUST be
ignored, with a warning, never treated as the newest-seen state on
the strength of being merely issuer-signed. Two distinct,
differently-content manifests presented at the same
(issuer, artifact_series, manifest_version) tuple
are an equivocation and MUST be rejected, not silently
accepted as if one superseded the other.¶
Currency is scoped per (issuer, artifact_series) pair: a
verifier holding persistent trust state MUST NOT
accept, for that pair, a manifest with a lower
manifest_version than the newest already accepted; on
regression it MUST report
trust: "unverified_rotation", the identical value the key-
manifest rotation rule above uses. A manifest lacking
manifest_version entirely (a legacy manifest) has no currency
ordering to violate and is never rejected on these grounds, only
warned.¶
Offline verification and trust bootstrapping. Offline
verification works from a local trust store of key manifests. A
manifest obtained from the issuer's own domain over TLS is the root
of trust: a verifier that resolved a manifest this way
MUST report trust: "verified" (absent a
discontinuous rotation). A manifest that arrived by any other path —
for example, embedded in an export bundle, never independently
fetched over TLS — is unauthenticated trust-on-first-use and
MUST be reported as
trust: "unauthenticated_tofu", never silently upgraded.¶
A verifier executes verify(envelope, trust_store,
revocation_view=None, disclosure=None), returning a layered
VerificationResult. Steps are executed in order; a step that
rejects the input short-circuits the remaining steps, and the
result's revocation and binding components take
their safe stub values ("unknown" and
"not_checked") whenever not reached.¶
The living specification numbers these steps 0 through 7; that numbering is preserved below as explicit labels rather than by list auto-numbering, since step 0 is not step 1.¶
attest_version is a version this verifier supports; the
signature block shape matches the selected profile
(Section 5).¶
payload.issuer.id; both the kid's domain prefix
and the resolved manifest's own issuer field
MUST equal it.¶
"compromised";
issued_at MUST fall within its validity
window. A "retired" key still verifies, with a
warning.¶
attest-receipt.schema.json revision-pinned at tag
v0.4.0), EXCEPT its Stage 3 (v0.2 §17.8)
holder-binding conditional on license.transferable and
buyer.pubkey, which belongs to the Stage 3 extension
(Section 12) and is OUTSIDE this profile's
normative scope — a verifier conforming to this document's
core/hybrid profile alone is not required to enforce it. This
document's own payload field registry
(Section 3.1) is a descriptive summary of
that same structure, not a second, independent normative source:
the schema, minus that one named conditional, is the precise
structural authority this step checks against.¶
revocation_view is supplied, and only if signature and
schema both succeeded; see Section 8.¶
disclosure
is supplied, and only if signature and schema both succeeded; see
Section 3.2.¶
| Component | Allowed values |
|---|---|
signature
|
valid |
invalid
|
schema
|
valid |
invalid | not_checked
|
revocation
|
unknown |
not_revoked_as_of:<T> | revoked |
invalid_revocation_ignored
|
binding
|
proven |
not_proven | not_checked
|
trust
|
verified |
unauthenticated_tofu |
unverified_rotation
|
trust is resolved as early as possible and reported at
its best-available value even when a later step rejects the receipt;
a verifier MUST NOT silently reset it on later
failure. ok is defined as: signature == "valid"
and schema == "valid" and
revocation != "revoked" and the result
carries no errors. invalid_revocation_ignored,
unknown, and any not_revoked_as_of:<T> value
do not affect ok.¶
An unrecognized top-level payload field is allowed and
signed, but MUST be reported as a warning, never an
error. A conforming verifier MUST also warn on: a
signing key resolved as "retired"; a "drm-bound"
license; an end_of_life value outside the seed vocabulary; a
revocation record that matched this receipt but failed
authentication, or that was ignored because
revocability == "none", or that authenticated but fell
outside a refund_window. Offline verifiers with no
revocation_view report revocation: "unknown"
honestly rather than failing the whole receipt closed.¶
Structural ceilings. A verifier MUST bound
the resource a hostile envelope or manifest can force it to spend
before any cryptographic or schema work runs, and these ceilings bind
every attest_version this document defines, including the
hybrid envelopes and manifests of Section 5.2. The
revocation-view record ceiling (Section 8) is a
distinct bound and is NOT one of these structural ceilings: it applies
only at Step 6, to the untrusted revocation_view input, never
before cryptographic or schema work runs, and is evaluated
independently of the table below.¶
| Ceiling | Value | Class |
|---|---|---|
| Raw envelope size | 1,048,576 bytes (220) | New; MUST accept within, MAY reject beyond |
| Parsed envelope tree nesting depth | 256 | Pre-existing parser bound; unconditional |
Issuer key manifest keys[] length |
256 entries | New; MUST accept within, MAY reject beyond |
Artifact manifest artifacts[] length |
4,096 entries | New; MUST accept within, MAY reject beyond |
A revocation record is a minimal, issuer-signed side-document:
receipt_id (the receipt it refers to), status
(only the literal value "revoked" carries revocation meaning
under the material this document specifies alone),
revoked_at (the record's own signed time — window checks are
evaluated against this, never the verifier's local clock), and
signature (Ed25519, plus for a hybrid-keyed issuer an
sig_ml_dsa_65 leg per the AND-rule of
Section 5.2, over JCS(record) with this
member removed).¶
Authentication. A verifier MUST treat a
revocation record as authenticated only if: its resolving key
manifest is itself self-consistent; its signing key resolves to a
key-entry with status == "active" — a compromised
or retired key's signature MUST be rejected
exactly as it would be on a receipt; revoked_at falls within
that key's validity window; and the signature verifies. A verifier
MUST fail closed (treat as unauthenticated) on any
malformed, wrong-typed, or missing input, rather than raising. An
unauthenticated record that nonetheless matches this receipt's
receipt_id MUST be ignored, with a
warning, never honored.¶
license.revocability
|
Effect of an authenticated, matching record |
|---|---|
none
|
Ignored: the record is itself
treated as invalid, revocation: "invalid_revocation_ignored",
a warning is emitted, ok is unaffected. This is the
irrevocability guarantee: without it, the revocation mechanism
would falsify every revocability: "none" receipt's own
claim. |
refund_window
|
Honored only if
revoked_at falls at or before
issued_at + revocation_window_days:
revocation: "revoked", ok becomes false. A
record that matches and authenticates but falls outside the
window is ignored, with a warning. |
policy
|
Honored as-is:
revocation: "revoked", ok becomes false. A
verifier cannot itself evaluate the referenced policy terms, so a
correctly signed record is trusted. |
Freshness anchor. T, in
not_revoked_as_of:<T>, MUST be computed
as the maximum revoked_at across all authenticated records
the verifier consulted in the supplied revocation view, regardless of
which receipt_id they target — it describes how current the
verifier's authenticated feed is, restricted to authenticated records
so that a forged far-future revoked_at cannot inflate the
reported freshness. With zero authenticated records the result
MUST be the bare literal unknown.¶
Revocation-view ceiling. A verifier MUST
bound the number of records it will evaluate from an untrusted
revocation view to 10,000. An oversized view is never truncated and
never raises; it fails closed for revocable receipts (an untrusted
view too large to evaluate cannot rule out a revocation, so it
MUST NOT certify the receipt: this is recorded as an
error, ok becomes false), while for
revocability: "none" receipts an oversized view is a
non-fatal warning instead, since a revocation record can never affect
ok for that class regardless of view size. This bound exists
independently of the Structural ceilings of Section 7:
it is a per-call record-count cap on trusted-input-shaped-as-untrusted
data (the revocation_view parameter), evaluated at Step 6,
never a wire-format or manifest-shape structural bound checked before
cryptographic or schema work runs.¶
This section distills the security-relevant properties of the
material specified above; the maintained, living threat model
(docs/spec/attest-threat-model.md in the attest source
repository) is the normative-in-intent, continuously updated
companion analysis and takes precedence over this summary.¶
Offline verification is trust-on-first-use unless a TLS root is
established. A verifier that has never fetched an issuer's key
manifest over TLS from that issuer's own domain reports
trust: "unauthenticated_tofu", never
"verified", and this document defines no registry or
alternate root of trust that upgrades that value; a relying party
that requires stronger provenance than TOFU must obtain the manifest
over TLS itself.¶
Key compromise and rotation continuity are fail-closed. A
key marked compromised invalidates every signature ever made
with it, and a discontinuous manifest rotation is reported, never
silently auto-accepted (Section 6). Both properties
depend on the verifier actually holding, or being able to validate, a
continuous manifest chain; a verifier that bootstraps trust from an
isolated manifest with no chain cannot detect a rollback to an
earlier, since-compromised state on its own.¶
Canonicalization strictness is deliberate. attest-JCS's duplicate-member rejection, integer-only number restriction, and lone-surrogate rejection (Section 4) exist so that two independent implementations parsing the same bytes always compute the identical signature input; a canonicalizer bug is a silent signature mismatch, not a loud parse error, which is why cross-language parity on the conformance corpus (Section 1) is load-bearing for this property rather than merely a testing convenience.¶
Signature malleability is closed by a pinned ruleset, not by trusting a library's defaults. The Ed25519 verification rules of Section 5.1 (non-canonical scalar rejection, small-order/non-canonical point rejection) exist because different cryptographic backends do not agree on malleability handling by default; implementations built on different backends are expected to disagree loudly at conformance-test time rather than silently accepting a malleable signature in the field.¶
The hybrid profile's post-quantum rationale is explicit and bounded. Section 5.2 defends against an attacker who can break exactly one of its two primitives, whether that break is a future cryptographically-relevant quantum computer against Ed25519 or a classical cryptanalytic advance against ML-DSA-65; it makes no claim about an attacker capable of breaking both simultaneously, and the mixed-keyset prohibition (Section 5.2) exists because an issuer that adopts the hybrid profile but leaves an Ed25519-only key active would otherwise silently forfeit that guarantee for any receipt forged under the still-active classical-only sibling.¶
A signature establishes what was signed, never why. Nothing in the material this document specifies distinguishes a voluntary signature from a coerced one: a revocation record produced under compulsion authenticates identically to one produced voluntarily. This is a permanent boundary of what a signature scheme can prove, not a gap this document proposes to close.¶
Structural ceilings bound resource exhaustion before
cryptographic work runs. The ceilings of
Section 7 (envelope size, manifest array lengths,
parse-tree nesting depth) exist so that a hostile envelope or manifest
cannot force a verifier to spend unbounded resources parsing or
evaluating it before any signature or schema check has a chance to
reject it outright. The revocation-view record ceiling
(Section 8) bounds a distinct, later-evaluated
input — the untrusted revocation_view supplied at Step 6,
only once signature and schema have both already succeeded — and
exists independently of the pre-crypto ceilings above, for the same
resource-exhaustion reason applied to that later, separately-supplied
input.¶
This section distills the privacy-relevant properties of the
material specified above; the maintained, living privacy
considerations document (docs/spec/attest-privacy.md in the
attest source repository) is the continuously updated companion
analysis, using the RFC 6973 threat vocabulary, and takes precedence
over this summary.¶
The buyer object's specified fields carry no plaintext
identifier. The buyer object's specified properties
are commitment, identifier_type, and
pubkey (Section 3.1); none of the
three is a plaintext identifier — commitment is a salted
commitment, pubkey a pseudonymous public key, and
identifier_type a non-personal enum
(issuer-account or email). This is a bounded
property, scoped to that
specified set, not a guarantee about the buyer object as a
whole: Section 3.1 permits every payload
object, buyer included, to carry additional, unlisted
properties, and a verifier running Section 7
MUST accept a nested unrecognized buyer
member as signed and valid, with no mandated warning. Such a member
is outside this guarantee and MAY carry a plaintext identifier.
Restricted to the specified field set, a verifier running
Section 7 without a disclosure never sees a plaintext
identifier there, and binding is not itself a component of
ok. The per-receipt salt required by
Section 3.2 means the same identifier commits to
an unrelated value in each receipt, confining a leaked salt's
exposure to that one receipt's commitment path.¶
buyer.pubkey is pseudonymous, not an identity
credential. It is optional, defaults to null for client-less
flows, and a verifier MUST NOT treat its equality
across two receipts as proof of buyer identity
(Section 3.2); this is a property this document
requires of a conforming verifier's own verdict, not a property the
wire format itself enforces against a party that chooses to reuse a
key.¶
Per-receipt salting is a commitment-path control, not a
general unlinkability guarantee. Two receipts issued to the same
buyer carry unrelated commitment values, and that is the
entire effect of the salting requirement. Every other stable value in
a pair of receipts — a reused buyer.pubkey, a non-null
supersedes pointer, an account handle placed in
work.identifiers, a shared kid, or a per-buyer
terms_uri — remains exactly what it was, and a party holding
two such receipts can still join them by any of these means. This
document does not claim, and implementers MUST NOT
represent, unlinkability across a buyer's whole receipt set as a
property the format provides; what it provides is that the
commitment values themselves do not supply the join.¶
Pseudonymity toward third parties is not anonymity toward the issuer: the issuer itself computes the commitment from an identifier it holds and generates the salt, so, for as long as it retains or can derive that material, it can recompute and recognize the corresponding commitments. Neither this profile nor the living specification requires or forbids that retention.¶
This document has no IANA actions.¶
This section is entirely non-normative. It points at material the living specification defines and normatively governs; nothing stated in this section imposes a requirement, and neither of the two profiles it summarizes is part of this document's own conformance surface (Section 1). A conforming implementation of this document alone implements neither.¶
The living specification (docs/spec/attest-v0.2.md,
Sections 7 through 16) defines an additive transparency layer: a
static, mirrorable append-only log substrate documented as a subset
of the C2SP tlog-tiles and tlog-checkpoint conventions, built on an
RFC 6962-style Merkle tree; hybrid Ed25519+ML-DSA-65-signed
checkpoints; an OpenTimestamps-based anchoring mechanism that proves
a checkpoint existed no later than a fixed, externally verifiable
point in time; and three purely informational
VerificationResult components —
transparency, corroboration, and
manifest_freshness — that a verifier implementing that
layer may additionally populate. The living specification states,
as a central and load-bearing property, that this layer never
upgrades trust and, with two narrowly scoped exceptions
it defines precisely, never changes signature,
schema, revocation, binding, or
ok for any receipt. None of this is specified normatively
by the present document; a reader who needs the exact mechanism is
directed to the living specification.¶
The living specification (docs/spec/attest-v0.2.md,
Section 17) defines an issuer-mediated protocol that gives the
reserved license.transferable field
(Section 3.1) its first assigned meaning: an
issuer-signed transfer record, logged in the transparency layer of
Section 12.1, that extinguishes an old receipt via a
new reachable value of the revocation component
(revocation: "transferred") and issues a new receipt to an
incoming holder, with a separate chain-of-title audit surface for
tracing a receipt's transfer history. This profile is layered on
top of the transparency layer of Section 12.1 and
requires it. None of this is specified normatively by the present
document, including the one new reachable revocation value it
introduces; a reader who needs the exact mechanism, its consent
gate, or its chain-of-title diagnostics is directed to the living
specification.¶
v0.4.0 — the released specification state carrying the
mirrored v0.1 revision 5 / v0.2 revision 6 (Section 1.1).
The tag is an immutable target, so a later change to the repository's
working schema cannot silently alter this document's normative payload
constraints. Its Stage 3 (v0.2 §17.8) holder-binding conditional is
outside this profile's normative surface (see Step 5).
docs/spec/attest-v0.1.md, docs/spec/attest-v0.2.md),
the JSON Schema (referenced normatively and revision-pinned as
[ATTEST-SCHEMA]), the standards-relationship annex
(docs/spec/attest-standards-relationship.md), the maintained
threat model and privacy considerations, and the cross-language
conformance vector corpus this document distills all live in this
repository. This document mirrors specific, declared revisions of
it; see Section 1.1.
This appendix is a compressed distillation of a companion document
in the attest source repository,
docs/spec/attest-standards-relationship.md
([ATTEST-REPO]), which is the canonical source for
this material and states each boundary at full length, including what
a future bridge to a given standard could look like. This appendix is
necessarily incomplete relative to that document and defers to it on
any point of detail.¶
W3C Verifiable Credentials [W3C.VC-DATA-MODEL]. The VC Data Model describes an open-world claim shape secured by a plurality of possible proof suites. attest's payload has a fixed required core with a single mandatory canonicalization profile (Section 4), made cross-language-safe by a shared conformance corpus rather than by proof-suite negotiation; the two models trade in opposite directions on purpose. An attest envelope could in principle be carried inside a credential's subject as an opaque, independently verifiable object; neither specification depends on the other.¶
eIDAS 2.0 and the EUDI Wallet. eIDAS 2.0 attests to identity and regulated attributes through a wallet-mediated, legally tiered apparatus (qualified and public-sector electronic attestations of attributes). attest attests to a merchant's grant of a license, verified entirely offline with no wallet, attestation provider, or Member State supervision anywhere in its path. The two frameworks attest to different kinds of fact for different kinds of relying party.¶
JOSE/JWS [RFC7515] and COSE [RFC9052]. Both formats sign the producer's own serialized payload bytes as transmitted (or a deterministic re-encoding derived from them), which is exactly what makes detached content a first-class feature of each. attest inverts that relationship: its signature input is the canonical re-derivation of the parsed payload (Section 4), recomputable from a parsed JSON object in any language with no side channel and no preserved wire form to keep — at the cost of requiring every implementation to canonicalize identically, which is exactly the cross-language risk the conformance corpus (Section 1) exists to make checkable rather than merely asserted.¶
RFC 8785 (JCS). attest-JCS (Section 4) is a restriction of, not an alternative to, [RFC8785]: every attest-JCS output is also a valid RFC 8785 output. What attest-JCS adds — the integer-only number restriction, duplicate- member rejection, and lone-surrogate rejection — narrows the accepted input set within RFC 8785's own envelope; it never departs from it.¶
C2PA [C2PA]. A C2PA manifest answers what an asset is and how it was produced. attest answers a different, adjacent question about the same asset: that a license to hold or use a copy of it was granted, by whom, to whom, and under what terms. A C2PA-signed asset and an attest receipt for the same underlying artifact are not in tension and can coexist without any new mechanism in either specification.¶
SCITT and RFC 9943. A SCITT "receipt" is a transparency service's proof that a signed statement was registered — evidence of inclusion in an append-only log. An attest "receipt" is the signed purchase statement itself (Section 2). The two protocols use one word for two different things. Where the living specification's Stage 2 log substrate (Section 12.1) genuinely does touch SCITT's territory — an append-only, Merkle-tree-based registration and inclusion-proof substrate — that overlap is real: attest's log applies the same class of transparency-log machinery to a narrower, purchase-evidence-specific evidence model, keeping the log strictly corroborative and never authoritative over a receipt's own signature-based validity.¶
RATS (RFC 9334). Restated from Section 2: "attest" is a project name chosen with no relationship to the RATS architecture, and this document's protocol makes no RATS claim — no Attester, Verifier, or Relying Party role mapping in the RATS sense, and no Evidence, Attestation Results, or execution-environment endorsement semantics.¶