# DFOS Protocol — Full Content Dump > All protocol site content as plain text. Specifications, overview, and FAQ. > Source: https://protocol.dfos.com --- # Why This Exists Not everything needs to be public to be verifiable. Some work is published for everyone. Some is shared with a few collaborators, a membership, or a private group. That choice should not decide whether the work's signed history can be checked independently. The DFOS Protocol separates the record from its delivery. A signed commitment circulates without the document it names. An application checks a signature without asking the original host to vouch for it. A reader who does receive the document checks that the bytes match the commitment. The purpose is not to eliminate hosting. It is to make hosting a service rather than the only place the evidence can be examined. ## Identity That Continues A DFOS identity begins with a signed operation, not a registration in a directory. The [`did:dfos`](https://protocol.dfos.com/did-method) identifier derives from that genesis operation, so re-deriving it from the chain is the check: a chain that does not derive the identifier it claims is rejected, whatever served it. Later operations change the identity's keys and services without changing the identifier, and every key that resolves proves possession of itself, bound to its role and its position in the chain. An application verifies a [sign-in](https://protocol.dfos.com/integrations) against that signed history rather than against an account at one host. A domain you control can point to the identity, with a matching claim recorded on the identity itself. The reference platform holds a signing key for every account by default, so ordinary use needs no key management. You can add keys the platform never holds. A controller key you hold is a fork right: from the last operation it controlled, you can continue the same identity on another relay, and who follows you there is a choice of relay. Any controller key can change the key set, the platform's included, and every change is a signed operation on the chain where anyone reading it can see it. ## Permissions You Can Delegate Authorization is a signed [credential](https://protocol.dfos.com/credentials), not a session at one service. A credential names the identity it authorizes and the resources and actions it covers. Delegation is linear and narrowing: each hop can drop resources, drop actions, and shorten expiry, and it can never widen what it was given. For credential-gated API calls, the recipient proves possession of its own key and binds that proof to the particular request, so a captured credential authorizes nothing without the audience key. A credential expires at the time it names and can be revoked before then, and a verifier enforces a revocation once it holds it. ## Where the Guarantee Stops Verification establishes who signed a record and whether a document matches its commitment. It does not establish that a signed claim is true: the chain shows what was signed, never who was at the keyboard. An identity chain is linear per view. A relay admits the first successor it sees at a position and refuses later ones, nothing in this corpus arbitrates between relays that admitted different successors, and which relay you read is which view you get. A signature proves who signed, forever, from anywhere. Whether a key is still effective, and what the head is, are statements about the freshest state a relay has. The protocol does not encrypt. A relay operator reads every blob it stores, and nothing obliges any relay to keep your chain: keep your own copies of your chain and your documents. [Guarantees](https://protocol.dfos.com/guarantees) states the full boundary in one page. ### Privacy Considerations The public record contains signed commitments and identity metadata. It does not need to contain the underlying document. The protocol commits to a document via an unsalted `dag-cbor` → SHA-256 CID. The commitment binds; it does not hide. Anyone holding the CID can test a candidate document against it and learn whether the guess was right, so a document drawn from a small or guessable space is not confidential against a party that holds its CID, and two chains that committed the same bytes are visibly the same bytes. Confidentiality of the documents themselves is enforced above the protocol, by whoever serves them. There is no end-to-end encryption here, and the security posture of a document is the security posture of the party serving it. ## What the Protocol Is The DFOS Protocol specifies how [Ed25519 signed chains](https://protocol.dfos.com/spec) establish identity, commit documents, and produce proofs. It defines [identifiers derived from a genesis operation](https://protocol.dfos.com/did-method) (`did:dfos`), [content-addressed commitments](https://protocol.dfos.com/content-model) via CID, and a [relay network](https://protocol.dfos.com/relay) of verifying HTTP endpoints that each re-verify what they distribute. Which view of an identity you follow is a choice of relay. Identity chains carry an optional discovery vocabulary: controller-signed `services` that say where to reach an identity and what stable content it anchors, projected into verified identity state alongside its keys. Witnesses can attach a standalone countersignature to any CID-addressable operation, carrying an optional open-namespace `relation` tag (`endorses`, `coauthors`, `witnessed`) that names the nature of the attestation. Verification is a function of the chains. A signed chain, the identity chains it names, and the revocations the verifier holds give valid or invalid. The signature check is one step of that: a verifier also needs canonical `dag-cbor` encoding, SHA-256, the identifier derivation, the chain rules, and, where authorization applies, the delegation walk. Standard cryptographic components are enough, and no DFOS software is required. There is no registry to query and no blockchain to sync. The reference implementation is in [TypeScript](https://www.npmjs.com/package/@metalabel/dfos-protocol). Cross-language verification exists in Go, Python, Rust, and Swift, all running against the same [deterministic test vectors](https://protocol.dfos.com/spec#reference-vectors) from the specification. ## What It Isn't - **Not a social protocol.** No federation model, no feeds, no application semantics. The protocol operates on keys and document hashes. [Application semantics](https://protocol.dfos.com/content-model) are a separate concern, free to evolve without protocol changes. - **Not a blockchain.** No consensus layer, no gas fees, no chain state to sync. Content-chain forks are valid, and convergence is deterministic without coordination: highest `createdAt` timestamp among tips, with lexicographic CID as tiebreaker. - **Not an encryption system.** Privacy comes from separation, not obscurity. The proof plane is fully public. The content plane is governed by application-layer access control. The protocol does not encrypt anything. - **Not coupled to the DFOS platform.** [DFOS](https://dfos.com) is one implementation. Any system implementing the same chain primitives produces interoperable, cross-verifiable proofs. ## Design Principles - **The identifier derives from the chain.** A DID is a deterministic hash of the genesis operation, so re-deriving it is the check. No external authority is needed to resolve it. - **Forks exactly where a merge exists.** Content chains are directed acyclic graphs, so forks are valid and convergence is deterministic without consensus: given the same set of operations, any relay computes the same head regardless of ingestion order. Identity chains are linear per view. Key state has no merge function, so a relay keeps its own log linear by first-seen admission, and two relays that admitted different operations at one position hold two views of the same identity. - **Transport-agnostic.** No privileged registry, blockchain, or API. A proof obtained from an API, a USB drive, or a peer-to-peer exchange verifies the same way. - **Offline-first.** Verification requires no network. The chain carries everything needed. - **Protocol-only.** Signed chains, CID derivation, [DID resolution](https://protocol.dfos.com/did-method), credentials, countersignatures. Application semantics are a [separate concern](https://protocol.dfos.com/content-model). ## Status The specification iterates in place and carries the version of the release it ships with. The cross-language test vectors ship with each release, and every implementation is checked against them. It is open source under the [MIT license](https://github.com/metalabel/dfos/blob/main/LICENSE). The DFOS platform runs on it in production. It has not been submitted to a standards body. The executable suites in [`packages/protocol-verify`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify) and [`packages/relay-conformance`](https://github.com/metalabel/dfos/tree/main/packages/relay-conformance) are the conformance definition. The [CLI](https://protocol.dfos.com/cli) ships pre-built binaries for Linux, macOS, and Windows, installable via Homebrew, Docker, or a single curl command. Discussion happens in the [DFOS](https://nce.dfos.com) space. Read the [full specification](https://protocol.dfos.com/spec), read what the protocol [guarantees and does not](https://protocol.dfos.com/guarantees), explore the [FAQ](https://protocol.dfos.com/faq), or browse the [source on GitHub](https://github.com/metalabel/dfos). --- # DFOS Protocol DFOS is an open protocol for identities, signed records, and delegated permissions that any compatible application can verify for itself. This document specifies the wire: the byte encodings, the identifier derivations, the chain rules, the possession rules, and the verification algorithms that make a DFOS operation the same object in every implementation. It is a wire specification plus the ingest and integration conventions that surround it. Authorship is verifiable without trusting any server. Which view of an identity you follow is a choice of relay. Scope: the protocol commits to content hashes, not plaintext. It does not encrypt. Confidentiality of the underlying documents is the application layer's, enforced by whoever serves them, and a relay operator can read what it stores. [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol) · [npm](https://www.npmjs.com/package/@metalabel/dfos-protocol) --- ## Overview | Component | Concern | | --------------------- | ------------------------------------------------------------------------------------------ | | **Crypto core** | Identity chains and content chains: Ed25519 signatures, JWS tokens, CID links | | **Credentials** | Delegated authorization; format in [CREDENTIALS.md](https://protocol.dfos.com/credentials) | | **Services** | Identity discovery vocabulary: controller-signed relay locators and stable content anchors | | **Artifacts** | Standalone signed inline documents: immutable, CID-addressable structured data | | **Countersignatures** | Standalone witness attestation: signed references to any CID-addressable operation | Documents are flat content objects, content-addressed directly: `documentCID = CID(dagCborCanonicalEncode(contentObject))`. What goes inside the content object is application-defined; the standard schema library is the [DFOS Content Model](https://protocol.dfos.com/content-model). ### Two chain types | | Identity Chain | Content Chain | | -------------- | -------------------------- | -------------------------------- | | Commits to | Key sets (embedded) | Documents (by CID reference) | | Identifier | `did:dfos:` | `` (bare) | | Operations | create, update, delete | create, update, delete | | JWS typ | `did:dfos:identity-op` | `did:dfos:content-op` | | Self-sovereign | Yes (signs own operations) | No (signed by external identity) | Both chains are signed linked lists of state commitments. Identity chains embed their state (key sets). Content chains reference their state via `documentCID`. ### Addressing Three addressing modes, self-describing by format: | Thing | Form | Example | | --------------------- | ------------------------ | ------------------------------------------ | | Operation or document | CID (dag-cbor + SHA-256) | `bafyrei...` (base32lower) | | Content chain | contentId (31-char hash) | `cv7n8vkvr64cctf3294h9k4eanhff8z` | | Identity chain | DID | `did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr` | CIDs address specific immutable artifacts: an exact operation or document. Content IDs and DIDs address living chains, derived as `customAlpha(SHA-256(genesis CID bytes))`. Same derivation for both. Identity chains prepend `did:dfos:` (W3C DID spec); content identifiers are bare. Application code may add prefixes for routing (e.g. `post_xxxx`). These are strippable semantic sugar, not part of the protocol identifier. --- ## Encoding Both operations and documents are content-addressed via **CID** (`dagCborCanonicalEncode(payload)` to SHA-256 to CIDv1). Operations are additionally signed via **JWS**. | Representation | Encoding | Purpose | | -------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | CID | `dagCborCanonicalEncode(payload)` → SHA-256 → CIDv1 | Deterministic content addressing for operations and documents | | JWS | `base64url(JSON.stringify(header))` + `.` + `base64url(JSON.stringify(payload))` → EdDSA signature covers both | Signature verification for operations | CID uses [dag-cbor canonical encoding](https://ipld.io/specs/codecs/dag-cbor/spec/): given the same logical payload, the CID MUST be identical regardless of implementation language or platform. JWS uses standard JSON for library interoperability. ### Standards | Component | Standard | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | Key generation | Ed25519 (RFC 8032) | | Signature algorithm | EdDSA over Ed25519 (pure, no prehash; Ed25519 handles SHA-512 internally) | | Key encoding | W3C Multikey (multicodec `0xed01` + base58btc multibase) | | Signed envelopes | JWS Compact Serialization (RFC 7515) with `alg: "EdDSA"` | | Content addressing | CIDv1 with dag-cbor codec (`0x71`) + SHA-256 multihash (`0x12`) | | ID encoding | SHA-256 → custom 19-char alphabet, 31 characters | | Timestamp encoding | ECMA-262 Date Time String Format, UTC form: a strict ISO-8601 / RFC 3339 profile, fixed millisecond precision (see Timestamp Grammar) | ### CID construction (dag-cbor + SHA-256) ``` 1. JSON payload → dag-cbor canonical encoding → CBOR bytes 2. SHA-256(CBOR bytes) → 32-byte hash 3. Construct CIDv1: - Version: 1 (varint: 0x01) - Codec: dag-cbor (varint: 0x71) - Multihash: SHA-256 (function: 0x12, length: 0x20, digest: 32 bytes) 4. CID binary = [0x01, 0x71, 0x12, 0x20, ...32 hash bytes] 5. Base32lower multibase encode → "bafyrei..." ``` dag-cbor canonical ordering: map keys sorted by encoded byte length first, then lexicographic. Strings to CBOR text strings. Null to CBOR null. Arrays to CBOR arrays. Objects to CBOR maps with sorted keys. The operation CID is derived from the JWS payload (the unsigned operation JSON), never from the JWS token: ``` operation CID = dagCborCanonicalEncode(operation_payload) → SHA-256 → CIDv1 → base32lower string ``` #### Number encoding **JSON numbers that are mathematically integers (no fractional part) MUST be encoded as CBOR integers (major type 0/1), never as CBOR floats.** This follows the [IPLD data model](https://ipld.io/docs/data-model/) integer/float distinction and the [dag-cbor codec spec](https://ipld.io/specs/codecs/dag-cbor/spec/). CBOR integer `1` encodes as `0x01` and CBOR float `1.0` encodes as `0xf9 0x3c 0x00`: the same logical value, a different CID. Languages that decode JSON into untyped maps (Go's `map[string]any`, Python's `dict`) typically represent all JSON numbers as floating-point. Implementations MUST normalize number types after JSON deserialization and before CBOR encoding. **Number bounds (normative).** A canonicalizable number MUST be an integer in the range `[-(2^53 - 1), 2^53 - 1]` (JSON's safe-integer range). Implementations MUST reject, at CID derivation and before CBOR encoding, any payload containing a non-integer number, `NaN`, `±Infinity`, or an integer outside that range. Applications that need fractional or larger-magnitude values MUST encode them as strings. This single form eliminates both the shortest-float divergence and the integer-versus-`float64` split above `2^53`. **Verification test vector**, encoding `{"version": 1, "type": "test"}`: ``` Integer encoding (CORRECT): CBOR: a2647479706564746573746776657273696f6e01 CID: bafyreihp6omsp6icc6ee63ox2ovsaxm6s7ikd2a7k5eh2qz2qd5soh5bsa Float encoding (WRONG — different bytes, different CID): CBOR: a2647479706564746573746776657273696f6ef93c00 CID: bafyreiawbms4476m5jlrmqtyvtwe5ta3eo2bh7mdprtomfgfype7j57o4q ``` The byte at offset 19 in the CBOR output is the discriminator: `0x01` is a CBOR integer, `0xf9` is a CBOR float16 header. #### String encoding String values are committed as their exact UTF-8 byte sequence. Implementations MUST NOT apply Unicode normalization (NFC, NFD, NFKC, NFKD) or any other transformation to string values before dag-cbor encoding or signing. The CID and the signature commit to the bytes as received, so two strings that are Unicode-equivalent but byte-distinct are different protocol values. #### JSON payload canonicalization The signed JWS payload is decoded as JSON, then re-encoded as dag-cbor for CID derivation. Producers MUST emit canonical JSON: object keys unique within each object, no duplicate object keys. Whitespace and key order in the source JSON do not affect the CID, because dag-cbor re-encodes from the decoded value. A payload containing duplicate keys is malformed: the signature commits to the raw payload bytes while the CID derives from the decoded value. #### Decoder recursion depth A node SHOULD apply a decoder recursion-depth guard when canonicalizing or encoding a payload. Both reference implementations cap nesting at 1024 levels. This is a local resource guard, not a chain-validity rule. Nodes MAY apply stricter local ingress limits (max bytes decoded off the wire, rate limits) provided they never accept an operation the validity rules reject, nor reject one they accept. ### ID alphabet ``` Alphabet: 2346789acdefhknrtvz (19 characters) Length: 31 characters Entropy: ~131.6 bits (19^31) ``` Process: `SHA-256(input) → for each of first 31 bytes: alphabet[byte % 19]`. The modulo introduces a ~0.3% bias (256 is not evenly divisible by 19), which is not security-relevant for identifiers. DIDs are `did:dfos:` + the 31-char ID derived from `SHA-256(genesis CID raw bytes)`: ``` DID = "did:dfos:" + idEncode(SHA-256(genesis_CID_raw_bytes)) ``` There is a single canonical identifier width. Verifiers MUST reject any `did:dfos:` identifier that is not exactly 31 characters over this alphabet, whether it appears in an operation's signing-key `kid`, in an operation payload, or as the DID of a resolved identity state. Key IDs are `key_` + a 31-char ID. Convention: derive from the key, `key_` + `customAlpha(SHA-256(publicKeyMultibase))`, where the input is the multibase **string** a chain carries, not the raw key bytes. This is not a protocol requirement; key IDs can be any string. #### DID derivation (worked example) ``` Input: CID bytes (hex) = 017112204e31ea9cb6ab4516ebdd812f7937e61601db07a16afb45723d286906f5181b69 Step 1: SHA-256(CID bytes) = c66d21f27dceea0b05534c225ad7018ac7d4dfded0609dcd18022a3739a5488c Step 2: Take first 31 bytes: c6 6d 21 f2 7d ce ea 0b 05 53 4c 22 5a d7 01 8a c7 d4 df de d0 60 9d cd 18 02 2a 37 39 a5 48 Step 3: For each byte, alphabet[byte % 19]: c6=198 → 198%19=8 → 'c' 6d=109 → 109%19=14 → 'n' 21=33 → 33%19=14 → 'n' f2=242 → 242%19=14 → 'n' ... Result: cnnnft9f8a2rn938d6nkz38r847v2kr DID: did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr ``` ### Multikey encoding (W3C Multikey for Ed25519) ``` Encode: 1. Take 32-byte Ed25519 public key 2. Prepend multicodec varint prefix [0xed, 0x01] (unsigned varint for 0xed = 237 = ed25519-pub) 3. Base58btc encode the 34-byte result 4. Prepend 'z' multibase prefix → "z6Mk..." Decode: 1. Strip 'z' multibase prefix 2. Base58btc decode → 34 bytes 3. First 2 bytes must be [0xed, 0x01] (ed25519-pub multicodec varint) 4. Remaining 32 bytes = raw Ed25519 public key ``` **Worked example:** ``` Public key (hex): ba421e272fad4f941c221e47f87d9253bdc04f7d4ad2625ae667ab9f0688ce32 Prefix + key (hex): ed01 ba421e272fad4f941c221e47f87d9253bdc04f7d4ad2625ae667ab9f0688ce32 Base58btc + 'z': z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb ``` `[0xed, 0x01]` is the unsigned varint encoding of 237 (`0xed`). Since `0xed > 0x7f` it requires two bytes in varint format: `0xed` (low 7 bits plus continuation bit) then `0x01` (high bits). This is NOT big-endian `[0x00, 0xed]`. ### Timestamp grammar Every operation's `createdAt` field MUST match exactly the grammar: ``` YYYY-MM-DDTHH:MM:SS.sssZ ``` That is, in order, with no other characters and no surrounding or internal whitespace: - a 4-digit zero-padded calendar **year** (`0000`–`9999`), - a literal `-`, a 2-digit zero-padded **month** (`01`–`12`), - a literal `-`, a 2-digit zero-padded **day** of month, - a literal uppercase **`T`** date/time separator, - a 2-digit zero-padded **hour** (`00`–`23`), `:`, a 2-digit **minute** (`00`–`59`), `:`, a 2-digit **second** (`00`–`59`), - a literal `.` followed by **exactly three** decimal digits of fractional seconds (millisecond precision, no more, no fewer), - a literal uppercase **`Z`** designating UTC. The value MUST be a real calendar instant: the month/day combination MUST be valid (leap years are honored, so `2024-02-29` is valid and `2023-02-29` is not). A **timezone offset** (e.g. `+00:00`, `-05:00`) MUST NOT appear; only the literal `Z` is permitted. **Leap seconds** (`:60`) MUST be rejected. A lowercase `z`, a missing or differently-sized fractional part, a space in place of `T`, non-zero-padded fields, or any leading, trailing, or embedded whitespace MUST be rejected. A verifier MUST reject any operation whose `createdAt` does not match this grammar. This applies on the read/verify path, not only at write time. The grammar is [ECMA-262 §21.4.1.32](https://tc39.es/ecma262/#sec-date-time-string-format)'s Date Time String Format in its UTC form, a strict profile of ISO 8601 / [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) that excludes the offset forms, week dates, ordinal dates, and comma separators those admit. The grammar is a validity rule, not a formatting suggestion: ordering and head selection compare `createdAt` as raw strings, and only a fixed-width zero-padded grammar makes byte order identical to chronological order. --- ## Chains ### Chain validity A valid chain is a sequence of operations rooted at a genesis. Each operation after genesis links to a predecessor via `previousOperationCID`. The chain provides structural ordering independent of timestamps. The two chain kinds differ in exactly one structural rule: whether that sequence may branch. **Identity chains are linear.** Each identity operation has at most one child. An identity chain, as verified, is one sequence: the head is its last operation, and identity state is what that sequence folds to. **Content chains are DAGs.** A valid content chain is a directed acyclic graph of operations. Two content operations referencing the same `previousOperationCID` constitute a fork, and both branches are accepted. The chain log stores all branches. A **deterministic head selection** rule makes every implementation holding the same set of operations compute the same head: 1. Find all **tips**: operations with no children. 2. Select the tip with the **highest `createdAt`** string (descending order). 3. If two or more tips share an identical `createdAt`, break the tie by the **highest CID multibase string** (descending order). Distinct payloads yield distinct CIDs, so this tiebreak is total. Semantic interpretation of a content-chain fork is application-defined. The protocol stores the DAG; clients interpret it. ### Views An identity chain is linear per view. Two identity operations that share a parent are two views of that identity, not an invalid log: a relay keeps its own log linear by admitting the first successor it sees for a position and refusing later ones, so what each relay serves is one linear chain. Choosing which view of an identity you follow is choosing a relay. On a content chain divergence needs no choice of view, because head selection resolves the same operation set to the same head everywhere. ### Comparison basis Both ordering comparisons, `createdAt` and the head-selection CID tiebreak, MUST compare the raw strings byte-wise (Unicode code point, left to right). An implementation MUST NOT parse `createdAt` to an epoch, a floating-point value, or a broken-down time before comparing, and MUST NOT apply a locale-aware or collation-aware comparison (for example ICU collation or `String.prototype.localeCompare`) to either field. The `createdAt` grammar is fixed-width and zero-padded and a CID is a base32-lower multibase string, so byte order is chronological order for the first and byte order for the second. ### Timestamp ordering `createdAt` MUST be strictly greater than the `createdAt` of the parent operation (the operation referenced by `previousOperationCID`). On an identity chain this is plain successor ordering. On a content chain it is enforced per-branch: a fork branch's timestamps are validated against its own parent, not the other branch's operations. `createdAt` is asserted by the signer. What bounds it is per-branch monotonic ordering plus the future bound below; there is no clock anyone else vouches for. ### Future timestamp bound Relays, and any component that performs deterministic head selection, MUST reject identity and content operations with a `createdAt` more than 24 hours in the future relative to the verifier's clock. Head selection favors the highest `createdAt`, so an unbounded future timestamp would otherwise dominate it permanently. Bare linear chain verification (`verifyIdentityChain` / `verifyContentChain`) does not select a head and does not enforce this bound; it validates only that each operation's `createdAt` is strictly greater than its parent's. The reference relays enforce the 24-hour bound at ingest. ### Identity chain signer validity An identity chain operation is valid only if the signing key was a **controller key in the immediately prior declared state** ([Key possession](#key-possession) defines declared versus effective state). Signer validity reads the declared set because it is a structural admission rule every relay must evaluate identically. A genesis operation declares exactly **one key**, the same key in all three role arrays, and is signed by it: the genesis introduces and simultaneously authorizes its own key, and the signature is that key's possession proof. A genesis declaring more than one distinct key across its role arrays is invalid. Identities with several controllers bootstrap the ordinary way: genesis with one key, then `update` operations introducing the rest under the possession rule. The identity chain defines its own valid signers via `controllerKeys` and the protocol enforces that. No external authority is consulted. ### Content chain signer model Content chain verification requires a **valid EdDSA signature** and delegates key resolution to the caller. The `kid` in each operation's JWS header is a DID URL (`did:dfos:#`). The verifier calls `resolveKey(kid)` to obtain the raw Ed25519 public key bytes for that key on that identity. How the resolver obtains and validates the identity's key state is application-defined. **Creator sovereignty.** The DID that signs the genesis (create) operation is the **chain creator** and permanently owns the chain. The creator can sign subsequent operations directly, with no credential. Other DIDs require a **DFOS credential with write access** in the operation's `authorization` field, issued by the creator DID. **Signer-payload consistency.** The `kid` DID in the JWS header MUST match the `did` field in the content operation payload. This discriminates author operations from countersignatures: if the `kid` DID differs from the payload `did`, it is a countersignature, not a chain operation. Which key role (auth, assert, controller) a signing key must hold, and any ownership or attribution semantics beyond creator sovereignty, are application concerns. What the protocol itself checks is [Content chain verification](#content-chain). ### Terminal states **Content chains: `delete` is the terminal state.** No valid content operations may follow a delete. This is enforced per-branch: a delete seals further linear extension of its own branch, forks rooted at a pre-delete operation remain valid, and head selection may make a non-deleted branch the head. Delete prevents future operations but removes no data; the complete chain remains intact for verification. Data removal is an application concern. **Identity chains: `active ⇄ deleted`, every transition an explicit signed operation.** A `delete` moves the identity to the deleted state and seals the chain against every operation except one: a **`restore`** operation MAY follow the `delete` as its immediate linear successor, returning the identity to the active state. An implementation MUST reject any other operation after a delete. Both transitions are ordinary operations in the one timeline, permanent and auditable, never removed. As with content chains, deletion removes no data. **Controller key requirement.** `update` operations on identity chains MUST include at least one controller key. If decommissioning is intended, `delete` is the correct terminal operation. **Content-null.** An `update` on a content chain with `documentCID: null` means the content exists but its document is cleared. The chain continues; a subsequent update can set content again. ### Operation versioning Every proof-plane operation payload (identity, content, artifact, countersign, revocation) carries a top-level integer `version` field. This document specifies version `1`; verifiers MUST reject any operation whose `version` is not exactly `1`. The operation `version` is distinct from content-document `$schema` versioning (see [CONTENT-MODEL.md](https://protocol.dfos.com/content-model)), which versions application payloads independently. ### Size and cardinality limits The protocol bounds operations with one aggregate size cap plus a small set of cardinality caps, never a per-field string-length table. The aggregate bound is measured over the exact CBOR bytes the CID commits to, so every implementation computes it identically. | Bound | Value | Applies to | | ---------------------------------- | ------------------------ | --------------------------------------- | | dag-cbor-encoded operation payload | **65536 bytes** (64 KiB) | identity operations, content operations | Verifiers MUST reject an identity or content operation whose `dagCborCanonicalEncode(payload)` exceeds 65536 bytes, **measured with any embedded `authorization` credential excluded**. Credentials are not subject to this cap; they carry their own 262144-byte (256 KiB) ceiling, because a maximum-depth delegation chain embeds each parent token in `prf` and exceeds 64 KiB. Total operation bytes are bounded by the sum (≤ 64 KiB + 256 KiB). The operation CID still commits to the complete payload including `authorization`. The `keyProofs` member of an identity `update` gets no such exclusion: its envelopes are ordinary payload members, inside the bytes the CID commits to and inside the 64 KiB measurement. Each envelope is separately bounded by the 4096-byte [key-proof envelope cap](#the-envelope). Artifacts keep their own 16384-byte cap; the `services` array keeps its 32768-byte cap. **Cardinality and field caps.** Each row states its own unit: item counts bound structure, and the one field cap is measured in UTF-8 bytes. | Field | Max | Note | | -------------------------------------------- | --------- | ----------------------------------------------- | | `authKeys` / `assertKeys` / `controllerKeys` | 256 items | Generous ceiling; op-size cap is the real bound | | `keyProofs` entries | 256 items | Bounds signature checks per operation | | `services` entries | 256 items | (see Services) | | countersignature `relation` | 64 bytes | Open-namespace tag (min 1 when present) | Beyond the caps above, the protocol does not limit field string lengths, document content size (the protocol commits to a CID, not the document, so large binary media is referenced rather than inlined), chain length, or number of chains per identity. These are application and transport concerns. --- ## Time basis Every verification has a **basis time**, and every temporal check in this corpus resolves against it. - For a **committed artifact**, an operation in a chain and anything carried inline in it, the basis is the operation's own `createdAt`. - For an **ephemeral presentation**, a request proof, a sign-in challenge, or a read-time credential check, the basis is now. At the basis: - the signing key MUST be effective in the identity's state as of the basis; - an `exp` MUST be strictly greater than the basis; - no revocation effective as of the basis may cover the artifact. Two rules stand outside the basis, and both are stated where they live. Issuer identity deletion is retroactive: while an identity is deleted the credentials it issued authorize nothing at any point in history, and verification of committed operations that relied on one rejects ([CREDENTIALS, Deleted issuers](https://protocol.dfos.com/credentials#deleted-issuers)). Admitting a new operation is answered from what a relay currently knows: a relay refuses an operation authorized by a credential it already holds a revocation for, whatever that operation's `createdAt` claims ([RELAY, Revocations and standing credentials](https://protocol.dfos.com/relay#revocations-and-standing-credentials)). An identity's **state as of a basis time** is the state produced by the last operation in the chain whose `createdAt` is less than or equal to the basis, with key memberships read from the effective (proved) state. For an ephemeral presentation the state is the head of the identity's chain as the relay answering the request serves it. Admission, not the clock, is what makes an operation current: an operation admitted with a `createdAt` in the future, within the [future timestamp bound](#future-timestamp-bound), is effective from the moment it is admitted, not from the instant its timestamp names. A basis earlier than the chain's genesis names no state: the identity did not exist, no key of it was effective, and an artifact with that basis does not verify. Credentials have no validity window of their own beyond `exp`; `iat` is informational and is not a rejection gate. Rotation therefore revokes going forward and leaves history valid: an operation committed while a key was effective still verifies after that key is rotated out, and the same key cannot sign a new ephemeral proof once it is gone. A **credit claim** runs no temporal check at all, so it has no basis time. The claimant's key is resolved against every key that claimant's identity chain has ever held. A claim binds a chain rather than a moment, so it survives the claimant's later rotations and the hosting document's later revisions ([CONTENT-MODEL, Credits](https://protocol.dfos.com/content-model#credits)). The ingest basis is derived from `createdAt` by converting to integer Unix seconds: ``` now_s = floor(createdAt_epoch_ms / 1000) ``` The conversion MUST truncate (floor) the millisecond remainder and MUST NOT round. A credential's `exp` is integer Unix seconds (JWT `NumericDate`), and the `exp` boundary is exclusive: a credential MUST be rejected as expired when `exp <= now_s`, including the exact instant `exp == now_s`. Two relays processing the same committed operation reach the same temporal verdict regardless of when each one ingests it. A relay MUST NOT add an ingest-time wall-clock `exp` check. --- ## Key possession Every key an identity chain lists is backed by a demonstration that its holder holds it and consented to listing it. The rule is bimodal, with no third case: 1. **The genesis key proves possession by signing genesis.** One key, declared across all three role arrays, signing the very operation that declares it. The signature is the proof, for all three roles at once. 2. **Every other key proves possession by an embedded key proof.** A key's introduction to a role is accompanied by an envelope the key itself signed, binding `{chain DID, role set, chain position}`. **Introduction.** An operation **introduces** key K to role R when K appears in the operation's R array and K was not in the immediately prior _effective_ R state. For each key it introduces, an `update` operation carries exactly one envelope in its `keyProofs` member whose `publicKeyMultibase` is K, whose `did` is the chain's DID, whose `prevCID` equals the operation's own `previousOperationCID`, and whose `roleSet` includes every role the operation introduces K to, verified per [Chain-walk verification](#chain-walk-verification). An operation that merely replays a key already effective in a role carries no proof for it: proofs live at introduction, and verification walks back to the introducing operation. A proof is spent at the position it names, so re-adding a removed key is a new introduction demanding a fresh envelope, and promotion into a role the key's envelope never covered is likewise a new introduction. There is no standing consent and no proof revocation; removal is an ordinary `update`. **Declared versus effective state.** An identity chain yields two readings of its key arrays. The **declared** state is structural: the arrays as the operations wrote them. The **effective** state is the declared state minus every unproved membership. A key-role membership whose introduction carries no valid covering envelope is **void**: excluded from the effective state for that role, never resolved for signature verification by consumers, never indexed, never surfaced in recovery. Void is not invalid, the operation and the chain stand, and tooling MUST surface void memberships rather than silently dropping them. **The three doors.** Possession is enforced at three surfaces: - **Writers hard-reject.** Software authoring an identity operation MUST refuse to produce an unproved introduction. The void path exists for reading hostile or defective chains, not as something conformant tooling emits. - **Relays sequence regardless.** A relay MUST admit and sequence every structurally valid identity operation (linearity, CIDs, timestamps, declared-state signer validity) whatever its proof status. Log membership never depends on semantic key validation. The relay's log answers _what was written_; proofs answer _what counts_. - **Verifiers compute void.** Every consumer of identity key state (signature resolution for content and API surfaces, key indexes, recovery oracles) reads the effective state. A chain whose effective controller set becomes empty is extendable in declared form but dead in effect: no key it lists can authenticate, assert, or authorize as the identity. Conformant writers cannot produce this state. ### The envelope A key proof is a compact JWS signed by the candidate key itself. Its payload is a JSON object of exactly seven members. The member set is **closed**: no free-form member, no content-bearing extension. A key proof proves a key and conveys no intent, content, or authority. | Member | Description | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `nonce` | The verifier-minted, single-use challenge string, taken verbatim from the ceremony that issued it. Opaque to the holder. A verifier SHOULD mint at least 128 bits of entropy. | | `audience` | The completing authority. In a hosted ceremony: the presentation endpoint's **lowercase authority**, bare hostname on the default HTTPS port and `host:port` on any other, never a scheme and never a path. In the controller-verified leg: the target chain's DID, byte-equal to this payload's own `did` member. | | `did` | The identity chain this key is being introduced to. | | `roleSet` | The [canonical role-set string](#the-roleset-grammar) naming the roles the key gains. | | `prevCID` | The chain head the introduction builds on: the CID the introducing operation carries as its `previousOperationCID`. | | `publicKeyMultibase` | The candidate key: the Multikey multibase string of the key this proof is about, which is also the key that signs it. | | `timestamp` | Creation time in the [timestamp grammar](#timestamp-grammar), floor-normalized to whole seconds (`.000Z` millisecond component). | **Canonical signing input.** The payload is serialized as minimal UTF-8 JSON (no insignificant whitespace) with members in exactly the order above: `nonce`, `audience`, `did`, `roleSet`, `prevCID`, `publicKeyMultibase`, `timestamp`. These bytes are the JWS payload segment. The rule binds both halves: a signer emits these bytes, and a verifier recomputes them from the members it parsed and refuses a payload segment that decodes to anything else. **Envelope size.** An envelope over **4096 bytes** is rejected before parsing. **The JWS header.** - `typ` is exactly `did:dfos:key-add`, the one registered key-proof purpose. - `alg` is the signature algorithm of the candidate key's Multikey type (`ed25519-pub`, signed as `EdDSA`). - `kid` is **absent.** The candidate key is in no chain, so there is no DID URL to name; the verification key rides in the signed payload. A present `kid` rejects. `crit` and embedded key members reject per the [Signature verification profile](#signature-verification-profile). **Self-proving.** The signature verifies against the payload's own `publicKeyMultibase`. A valid envelope is possession demonstrated over challenge bytes. #### The roleSet grammar `roleSet` names the subset of the chain's three key roles the introduction covers, as one canonical string: members drawn from exactly `auth`, `assert`, `controller`; serialized as the subset in the fixed order `auth,assert,controller`; comma-joined; no whitespace; no duplicates; never empty. `auth,assert` and `controller` are canonical spellings; `assert,auth`, `auth, assert`, and `auth,auth` are schema violations. #### Position binding The `did`, `roleSet`, and `prevCID` members bind the proof to one introduction at one position: - `did` binds the proof to one chain. An envelope signed for one identity does not verify for any other. - `roleSet` binds the proof to the roles gained. A key's membership in a role is proved only by an envelope whose `roleSet` includes that role. - `prevCID` binds the proof to one chain position. A removed key cannot be re-added on the strength of an old envelope: the re-adding operation has a different `previousOperationCID`, so the old proof does not cover it. #### The two legs An introduction is verified by whoever appends it, and `audience` names that party in the value domain the flow has. On the **hosted ceremony leg** a ceremony operator custodying the chain mints the nonce and `audience` is that host's authority, which the verifier byte-compares against its own configured authority, never against anything request-derived. On the **controller-verified leg** the chain's own controller mints the nonce and delivers `{did, roleSet, prevCID, nonce}` to wherever the candidate key lives, and `audience` MUST byte-equal the payload's `did`. A host authority is never a DID, so the two value domains never overlap and an envelope signed for one leg cannot verify in the other. Everything between minting a nonce and adopting the returned envelope is an integration convention rather than a chain rule, and it is specified in [INTEGRATIONS, Key ceremonies](https://protocol.dfos.com/integrations#key-ceremonies): the short-code and URI carriage, the `/.well-known/dfos-key-proof` resolution endpoint and its JSON, the holder's obligations, and the presentation check a verifier runs before embedding the bytes. A chain walker re-runs none of it; what a walker checks is below. ### Chain-walk verification A verifier walking an identity chain checks, for each embedded envelope: 1. **Header gates.** `typ` MUST be `did:dfos:key-add`; `alg` MUST be the algorithm of the payload key's Multikey type; `crit`, embedded key members, and a present `kid` reject. 2. **Payload schema, over canonical bytes.** Exactly the closed seven-member schema, each member a string, `roleSet` in the canonical grammar; the walker recomputes the canonical signing input from the parsed members and byte-compares it against the payload octets, and a mismatch rejects. 3. **Key.** `publicKeyMultibase` MUST equal the introduced key's multikey. 4. **Chain.** `did` MUST equal the chain's own DID. 5. **Position.** `prevCID` MUST equal the carrying operation's `previousOperationCID`. 6. **Coverage.** `roleSet` MUST include the role this walk is checking. The walk runs once per introduced key-role membership, so coverage is per role: an envelope covering some of the roles an operation introduces the key to proves those and leaves the rest void. 7. **Signature.** The JWS MUST verify against the payload's `publicKeyMultibase`. `nonce`, `audience`, and `timestamp` are presentation-time transport, byte-fixed under the signature and inert at walk time: a walker re-checks neither freshness nor audience nor nonce state. A failed walk-time check makes the introduction **void**, excluded from the chain's effective key state. It never invalidates the operation or the chain. --- ## Operation schemas ### Identity operations ```typescript // Genesis — starts the identity chain { version: 1, type: "create", authKeys: MultikeyPublicKey[], // exactly one entry — assertKeys: MultikeyPublicKey[], // the same single key in all three controllerKeys: MultikeyPublicKey[], // arrays, which also signs (see Key Possession) services?: ServiceEntry[], // discovery vocabulary (optional) createdAt: string } // timestamp grammar — ms precision, UTC // Key rotation / modification { version: 1, type: "update", previousOperationCID: string, // CID of previous operation authKeys: MultikeyPublicKey[], assertKeys: MultikeyPublicKey[], controllerKeys: MultikeyPublicKey[], // must have at least one keyProofs?: string[], // one key-proof envelope per key this // op introduces (see Key Possession) services?: ServiceEntry[], // full-state — REPLACES the prior set createdAt: string } // Deactivation — moves the identity to the deleted state { version: 1, type: "delete", previousOperationCID: string, createdAt: string } // Undeletion — only valid as the immediate successor of a delete { version: 1, type: "restore", previousOperationCID: string, // MUST be the CID of a delete operation createdAt: string } ``` The optional `services` array is full-state discovery vocabulary projected into verified identity state (see [Services](#services)). Omitting it encodes identically to a service-less operation (CID-neutral); an `update` carrying it REPLACES the entire prior set; a `delete` carries the last set unchanged. The optional `keyProofs` array carries the key-proof envelopes for the keys an `update` [introduces](#key-possession): compact JWS strings, embedded verbatim as presented, one per introduced key. It is defined on `update` only; a `create`, `delete`, or `restore` carrying it is invalid. An operation that introduces no key carries none. Omitting it encodes identically to an empty introduction (CID-neutral), the same rule as `services`. **`restore` validity (normative).** `restore` is the one operation that may follow a `delete`, and the successor-of-delete position is the only position in which it is valid: - Its `previousOperationCID` MUST reference a `delete` operation. A `restore` anywhere else in the chain is invalid. - It MUST be signed by a **controller key of the deleted head state**, the key state produced by the `delete` it restores. A key rotated out before the delete is not in that state and grants nothing. - Its effect is exactly to clear the deleted state: the identity returns to active with the keys and services as of the delete, **verbatim**. `restore` carries no key sets or services of its own; state changes happen via subsequent ordinary `update` operations. - Its `createdAt` follows the ordinary rule: strictly greater than its parent's. There is no rate limit and no once-only rule; every transition requires a current controller key, which is total authority over the identity anyway. A protocol-level irreversible deletion that no controller key can reopen is not part of this specification. ### Content operations ```typescript // Genesis — starts the content chain, commits initial document { version: 1, type: "create", did: string, // author DID, committed to by CID documentCID: string, // CID of flat content object baseDocumentCID: string | null, // committed-but-uninterpreted provenance createdAt: string } // Content change (null documentCID = clear content) { version: 1, type: "update", did: string, // author DID previousOperationCID: string, documentCID: string | null, baseDocumentCID: string | null, // committed-but-uninterpreted provenance createdAt: string, authorization?: string } // DFOS credential for delegated operations // Permanent destruction { version: 1, type: "delete", did: string, // author DID previousOperationCID: string, createdAt: string, authorization?: string } // DFOS credential for delegated operations ``` `baseDocumentCID` is committed-but-uninterpreted provenance, validated as CID-or-null with no verification meaning. It lets the public proof plane express content-version lineage without exposing the private document. ### MultikeyPublicKey ```typescript { id: string, // e.g. "key_r9ev34fvc23z999veaaft83nn29zvhe" type: "Multikey", // literal discriminator publicKeyMultibase: string } // e.g. "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" ``` --- ## JWS envelope format ### Signing ``` signingInput = base64url(JSON.stringify(header)) + "." + base64url(JSON.stringify(payload)) signature = ed25519.sign(UTF8_bytes(signingInput), privateKey) token = signingInput + "." + base64url(signature) ``` ### kid rules | Context | kid format | Example | | ------------------------------ | ----------- | ------------------------------------- | | Identity create (genesis) | Bare key ID | `key_r9ev34fvc23z999veaaft83nn29zvhe` | | Identity update/delete/restore | DID URL | See below | | All content ops | DID URL | See below | DID URL examples: ``` did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_ez9a874tckr3dv933d3ckdn7z6zrct8 ``` ### `cid` header Every operation JWS (identity-op and content-op) includes a `cid` field in the protected header: the CIDv1 string of the operation payload, derived from `dagCborCanonicalEncode(payload) → SHA-256 → CIDv1 → base32lower`. The `cid` is computed before signing and embedded in the protected header, so the EdDSA signature covers it. **Signing order:** 1. Construct the operation payload 2. Derive the operation CID: `dagCborCanonicalEncode(payload) → CIDv1` 3. Build the protected header including `cid` 4. Sign: `ed25519.sign(UTF8(base64url(header) + "." + base64url(payload)), privateKey)` **Verification rule.** After verifying the JWS signature and deriving the operation CID from the parsed payload, implementations MUST reject operations where `header.cid` is missing, or `header.cid` does not match the derived CID. Which envelope families carry `cid` is inventoried per family in the [extension registry](#extension-registry). ### `typ` header The JWS `typ` header uses protocol-specific values, not IANA media types. Every `typ` value is registered in the [extension registry](#extension-registry), which names each value, the spec that owns it, and whether its envelope carries the `cid` header. A new envelope family adds a row there, never a local name. Registration is for `typ` routing and says nothing about ingestion; several registered families are document-plane artifacts no relay ingests. Where a family's verification requires an exact `typ`, that check is normative and is stated with that family's rules: key proofs, credentials and revocations, SIWD challenge and ask proofs, API-AUTH request and identity proofs, and sign-requests all gate on it, and it is what keeps a JWS signed for one purpose from being presented as another. Elsewhere `typ` is routing: implementations SHOULD validate it and MUST NOT treat it as a substitute for the checks a family's own rules require. --- ## Signature verification profile DFOS pins one narrow profile of the JOSE/JWS surface so that all conformant verifiers accept and reject the same signatures byte-for-byte. The rules below are normative and apply to **every** verification path: identity-op JWS, content-op JWS, key proofs, artifacts, countersignatures, DFOS credentials, credential revocations, and every API-AUTH and SIWD proof. A verifier MUST apply §1 to §3 to the protected header **before** performing any signature computation, and MUST apply §4 as part of or before the signature check. A token that violates any rule MUST be rejected regardless of whether its signature would otherwise verify. There is no algorithm agility: the verifier never branches on `alg` to select a primitive. Ed25519 (`EdDSA`) is the only signature algorithm. ### 1. Algorithm pinning (`alg`) The protected header `alg` member MUST equal the exact string `"EdDSA"`. Any other value MUST be rejected before any signature check, including `"none"`, `"HS256"`, `"RS256"`, `"ES256"`, the lowercase `"eddsa"`, or an absent `alg`. Verifiers MUST NOT use `alg` to choose a verification primitive; it is checked only for exact equality. ### 2. `crit` rejection The protected header MUST NOT contain a `crit` member. DFOS emits no critical header parameters, so any token whose protected header carries `crit`, with any value, MUST be rejected. Verifiers MUST observe the member's presence directly: decoding into a fixed header shape that silently discards unknown members is not sufficient. ### 3. No header key trust The verifier MUST NOT read key material from the protected header, and MUST NOT follow a reference that fetches it. A protected header carrying any of `jwk`, `jku`, `x5c`, or `x5u` MUST be rejected. A URL that fetches a key is header key trust with an extra hop. The signing key is resolved exclusively from `kid` against the signer's identity chain, in that identity's effective state as of the verification's [basis time](#time-basis). A key proof is the one family that carries no `kid`: its key rides in the signed payload, per [The envelope](#the-envelope). A header-supplied key is never trusted, even if it happens to match the resolved key. ### 4. Canonical signature scalar (`S < L`) An Ed25519 signature is `R || S` (64 bytes). The scalar `S`, the trailing 32 bytes little-endian, MUST be canonical: strictly less than the group order ``` L = 2^252 + 27742317777372353535851937790883648493 = 0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed ``` A signature whose `S >= L` MUST be rejected. A signature that does not decode to exactly 64 bytes MUST also be rejected. An implementation on an Ed25519 library that accepts a non-canonical `S` MUST add an explicit constant-time `S < L` gate. ### Axes outside this profile Verifiers inherit whatever their Ed25519 library does on these axes: cofactorless verification equation pinning (the specific `[S]B == R + [k]A` equation rather than the batch or cofactored form), the full-order public key check (`[L]A == identity`), canonical point encoding (`y < p`) for `R` and `A`, small-order public key rejection, and strict base64url tightening. --- ## Credentials Credentials authorize actions on resources (read, write) via attenuations, for relay access and content chain delegation. The credential format, verification rules, and revocation payloads are specified in [CREDENTIALS.md](https://protocol.dfos.com/credentials). ### Content chain authorization When `enforceAuthorization` is enabled on content chain verification: 1. **Genesis operation.** The signer is the chain creator, always authorized. 2. **Creator signs subsequent ops.** Authorized directly, no credential needed. 3. **A different DID signs.** The operation MUST include an `authorization` field containing a valid DFOS credential where: - The delegation chain roots at the chain creator DID - The credential's `att` includes an entry with `action: "write"` covering this chain's resource - The credential is valid at the operation's [basis time](#time-basis): the issuer's signing key effective as of the basis, `exp` strictly greater than the basis, and no revocation effective as of the basis covering it or any parent in its delegation chain The `authorization` field is available on `update` and `delete` content operations. It is absent for creator-signed operations. ### Revocation Credentials are revoked by publishing a **revocation artifact**, a signed proof-plane primitive with `typ: did:dfos:revocation`. Only the credential's issuer DID can revoke it, and there is no un-revoke operation. Revocation is checked at **every level** of a presented credential, the leaf and each parent in its delegation chain, on both the read path and the write path. Revocation is forward-looking against the basis: it prevents future use of a credential and does not retroactively invalidate operations already committed to a content chain. --- ## Services `services` is an identity's **discovery vocabulary**: a controller-signed, full-state array carried in identity-chain `create` and `update` operations and projected into verified identity state. It answers "given a DID, where do I reach this identity, and what stable content does it publish?" Services live inside identity operations, inherit the chain's signer rules, and inherit the chain's linearity, so one log resolves to exactly one services set. ### Service entry ```typescript { id: string, // did-core fragment, unique within the set (deref did:dfos:xxx#) type: string, // open namespace — recognized types are structurally validated ... // type-specific fields (see below) } ``` Every entry carries the common envelope `{ id, type }`. The namespace is **open**: two types are recognized and structurally validated, and any other `type` is an opaque extension that verifiers MUST preserve verbatim and otherwise ignore. New service types therefore never require a protocol or cross-language change. Every registered type is indexed in the [extension registry](#extension-registry). **Recognized types:** ```typescript // Transport locator — where to reach a relay serving this identity { id: string, type: "DfosRelay", endpoint: string } // endpoint: bare URL string // Stable content reference under a client-defined semantic label { id: string, type: "ContentAnchor", label: string, anchor: string } ``` A `ContentAnchor`'s `anchor` references a stable content identifier, dispatched by structural form: | Anchor shape | Resolves to | | ----------------------------- | --------------------------------- | | `^[2346789acdefhknrtvz]{31}$` | content chain (mutable, gateable) | | `^bafyrei[a-z2-7]{52}$` | artifact (immutable, public) | These two shapes are the ONLY valid anchors and the structural dispatch above is normative. An anchor matching neither shape MUST be rejected (`AnchorInvalid`); verifiers do not accept other CID codecs or lengths. New anchor kinds arrive via a new service `type`, never a new anchor shape. The `label` is an opaque client-semantic key (for example `"profile"`, `"avatar"`). The protocol assigns it no meaning. ### Bounds - ≤ 256 entries per identity; entry `id`s MUST be unique within the set - `id`, `type`, and the recognized string fields (`endpoint`, `label`) MUST be non-empty, and `anchor` MUST match the contentId or CID shape. Individual field lengths are not separately capped - The CBOR-encoded `services` array MUST NOT exceed **32768 bytes** - An entry whose **recognized** type is structurally malformed (for example a `DfosRelay` without an `endpoint`) MUST be rejected at verification. A malformed **unrecognized** type is preserved and ignored ### Full-state semantics `services` is full-state, not a delta. A `create` sets the initial set; an `update` REPLACES the entire set (omit the field to clear it); a `delete` carries the last set unchanged into terminal state. Omitting `services` encodes identically to a service-less operation (CID-neutral). ### Worked example `examples/identity-services.json` is a genesis publishing a relay locator and two content anchors, one content-chain and one artifact. Signed by reference key 1: ``` did: did:dfos:krhcznk98f7r2r4a6ktafcv77f7k6e2 typ: did:dfos:identity-op cid: bafyreiasjg3vqs4b3vepwy5qc4oy4f4vpkcahmwi64jtuei5cf7zqpdxjy services: [ { id: "relay", type: "DfosRelay", endpoint: "https://relay.dfos.com" }, { id: "profile", type: "ContentAnchor", label: "profile", anchor: "8n8fnzhrrefkrde6h72kfvff43r8c63" }, { id: "avatar", type: "ContentAnchor", label: "avatar", anchor: "bafyreie6xfkrtwax2dq5gdw3rpsurz2glsduxycfhk7jjllewiwivkkafu" } ] ``` The full JWS token is in [`examples/identity-services.json`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/examples/identity-services.json). --- ## Standalone signed statements An artifact and a countersignature are each one signed statement with no predecessor and no successor: a JWS envelope over a dag-cbor payload, addressed by the payload's CID, verified against the signing identity's state as of the statement's own `createdAt` ([Time basis](#time-basis)). Neither is a chain operation: nothing links to one by `previousOperationCID`, and neither changes chain state. ### Payload shape | Field | Artifact | Countersignature | | ----------- | -------------------------------------------- | -------------------------------------------------------- | | `version` | `1` | `1` | | `type` | `"artifact"` | `"countersign"` | | `did` | The signing identity | The witness identity | | body | `content`: an object with a `$schema` string | `targetCID`: the CID witnessed; `relation`: optional tag | | `createdAt` | [Timestamp grammar](#timestamp-grammar) | Same | The JWS `typ` is `did:dfos:artifact` or `did:dfos:countersign`, and the envelope carries the [`cid` header](#cid-header). **Artifact.** `content` is an inline document. Its `$schema` is a free-form discriminator with no protocol-level registry; consumers dispatch on it. The CBOR-encoded payload MUST NOT exceed 16384 bytes. An artifact is immutable and is addressed by its CID. ```json { "version": 1, "type": "artifact", "did": "did:dfos:...", "content": { "$schema": "https://schemas.dfos.com/profile/v1", "name": "Example" }, "createdAt": "2026-03-25T00:00:00.000Z" } ``` **Countersignature.** `targetCID` names any CID-addressed statement: a content or identity operation, an artifact, or another countersignature. `relation` is an optional open-namespace tag of 1 to 64 UTF-8 bytes. Recognized values (`endorses`, `coauthors`, `witnessed`, `holds`, `received`) inform clients; unrecognized values MUST be preserved and ignored. When present, `relation` is part of the canonical payload and so of the CID; absent, the payload encodes identically to one that never carried it. A countersignature is permanent. There is no withdrawal; a consumer may weight a newer statement by the same witness over an older one. Witnessing publicly and permanently links the witness DID to the target. ```json { "version": 1, "type": "countersign", "did": "did:dfos:witness...", "targetCID": "bafy...", "relation": "endorses", "createdAt": "2026-03-25T00:00:00.000Z" } ``` ### Verification 1. Decode the JWS. `typ` MUST be the value registered for the payload `type`. 2. Validate the payload: `version`, `type`, `did`, `createdAt`, and the body for that type (`content` carrying a `$schema` string, or `targetCID` with a well-formed `relation` when present). 3. The `kid` DID MUST equal the payload `did`. 4. Verify the EdDSA signature against the key `kid` names, effective in the signer's state as of `createdAt`. 5. `header.cid` MUST equal the CID of the dag-cbor canonical encoding of the payload. 6. Artifact only: the CBOR-encoded payload MUST NOT exceed 16384 bytes. A relay's checks on a countersignature (the target exists, the witness is not the target's author, one countersignature per witness per target) are ingest rules, specified in [RELAY](https://protocol.dfos.com/relay#artifacts-and-countersignatures-on-the-wire). --- ## Verification Every signature check below is performed under the [Signature verification profile](#signature-verification-profile). ### Identity chain 1. Decode each JWS, parse payload as IdentityOperation 2. First op MUST be `type: "create"`, the genesis bootstrap: - Verify the [single-key rule](#key-possession): each of the three role arrays holds exactly one entry, all three the same key with the same id. A genesis declaring more than one distinct key MUST be rejected. A genesis carrying `keyProofs` MUST be rejected. - The one key declared in the genesis payload is trusted because the identity does not exist before this operation. - The signing key (resolved from `kid`) MUST be that declared key. - Derive the operation CID via dag-cbor canonical encoding. Verify `header.cid` matches the derived CID. Derive the DID from the CID. 3. For each subsequent op: verify `previousOperationCID` matches the previous op's derived CID, so the chain as verified is one linear sequence. Verify `createdAt` is strictly greater than the parent operation's `createdAt` (MUST). 4. If the state before this operation is deleted, the only valid operation is a `restore` whose `previousOperationCID` is the delete's CID; any other operation MUST be rejected. A `restore` whose parent is not a `delete` MUST be rejected wherever it appears. A `delete` or `restore` carrying `keyProofs`, and any `keyProofs` member that is not an array of strings, MUST be rejected. 5. Resolve `kid`: genesis uses a bare key ID, non-genesis uses a DID URL (extract the DID, verify it matches the derived DID; extract the key ID). 6. Find the controller key matching that key ID **in the current declared state**, the declared state after all preceding operations. For a `restore` this is the deleted head state produced by the `delete`, which carries the last key sets unchanged. Decode multikey to a raw Ed25519 public key. 7. Verify the EdDSA JWS signature over the signing input bytes. 8. For an `update`, compute possession per [Key possession](#key-possession): for each key-role membership the operation introduces, find an envelope in `keyProofs` passing [chain-walk verification](#chain-walk-verification) for this operation, this key, and that role. A covered introduction is effective; an uncovered one is **void**, recorded and surfaced, excluded from effective state, and not a rejection. 9. Apply the state change: `create` initializes declared and effective key state to its one key in all three roles; `update` replaces declared state (which must have at least one declared controller key) and folds effective state per step 8; `delete` marks the state deleted (key sets and services carried unchanged); `restore` clears the deleted state (keys and services as of the delete, verbatim, effective state included). The verification result carries both readings: the declared state, the effective state, and the list of void memberships (key, role, operation CID). Consumers act on the effective state; the declared state exists for structural admission and for showing a human what a chain claims versus what it proved. ### Content chain 1. Decode each JWS, parse payload as ContentOperation 2. First op must be `type: "create"`; the signer is the chain creator 3. For each subsequent op: verify `previousOperationCID` matches, verify `createdAt` is strictly greater than the parent operation's `createdAt` (MUST) 4. Derive the operation CID via dag-cbor canonical encoding. Verify `header.cid` matches the derived CID. 5. Verify the `kid` DID matches the payload `did` field 6. Resolve `kid` via the external key resolver the caller provides 7. Verify the EdDSA JWS signature 8. If `enforceAuthorization` is enabled and the signer DID differs from the chain creator: verify the `authorization` field contains a valid DFOS credential with `action: "write"` covering this chain, with a delegation chain rooting at the creator DID, valid at the operation's [basis time](#time-basis) 9. Apply the state change (set document, clear, or delete) --- ## Extension registry Every name the corpus registers under the core's open namespaces is indexed here: service types under [Services](#services), and JWS `typ` values under the [`typ` header](#typ-header) convention. Each name's semantics live in its owner spec, which is normative wherever the two could be read to disagree. A new name lands by adding its row here in the same PR that specifies it. ### Service types | Service `type` | Owner spec | Validation | Semantics | | ------------------------- | ------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------ | | `DfosRelay` | [PROTOCOL](#services) | core | Transport locator: where to reach a relay serving this identity. | | `ContentAnchor` | [PROTOCOL](#services) | core | Stable content reference: a contentId or artifact CID under a client-defined semantic label. | | `DfosAuthorizationServer` | [INTEGRATIONS](https://protocol.dfos.com/integrations#finding-the-authorize-endpoint) | consumer | The canonical authorize origin able to produce this subject's signature under sign-in profile A. | | `DfosOrigin` | [INTEGRATIONS](https://protocol.dfos.com/integrations#the-dfosorigin-service-entry) | consumer | The identity's claimed web domain: the chain half of the bidirectional origin binding. | **Validation.** `core`: structurally validated by every conformant verifier, and a malformed entry rejects at verification. `consumer`: opaque to the core (preserved verbatim, ignored); structural validation is an obligation of the owner spec's consumers. Ambiguity rules, field grammar, and display discipline are the owner spec's. ### JWS `typ` values Every DFOS JWS envelope is typ-scoped. The `cid` column marks whether the envelope carries the protocol's [`cid` header](#cid-header). | `typ` value | Owner spec | `cid` | Semantics | | ------------------------- | ------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `did:dfos:identity-op` | [PROTOCOL](#typ-header) | yes | Identity chain operations. | | `did:dfos:content-op` | [PROTOCOL](#typ-header) | yes | Content chain operations. | | `did:dfos:artifact` | [PROTOCOL](#standalone-signed-statements) | yes | Standalone signed statements: inline documents. | | `did:dfos:countersign` | [PROTOCOL](#standalone-signed-statements) | yes | Standalone signed statements: witness attestations. | | `did:dfos:key-add` | [PROTOCOL](#key-possession) | no | Key introduction proofs: the candidate key's position-bound possession-and-consent proof, self-signed, presented to ceremonies and embedded in the introducing identity operation's `keyProofs`. | | `did:dfos:credential` | [CREDENTIALS](https://protocol.dfos.com/credentials) | yes | Authorization credentials; the `cid` is their revocation address. | | `did:dfos:revocation` | [CREDENTIALS](https://protocol.dfos.com/credentials) | yes | Credential revocation artifacts. | | `did:dfos:credit-claim` | [CONTENT-MODEL](https://protocol.dfos.com/content-model) | yes | Document-plane credit claims: registered for `typ` routing, never relay-ingested. | | `did:dfos:sign-request` | [RELAY](https://protocol.dfos.com/relay#the-sign-request-envelope) | yes | Sign-request envelopes: travel the signing-mailbox courier, never `POST /proof/v1/operations`. | | `did:dfos:siwd` | [INTEGRATIONS](https://protocol.dfos.com/integrations#sign-in) | no | Sign In With DFOS challenge proofs: delivered by web redirect or the signing mailbox, never relay-ingested. | | `did:dfos:siwd-ask` | [INTEGRATIONS](https://protocol.dfos.com/integrations#the-ask-proof) | no | Loopback client ask proofs: the client's key-control proof over its own authorize request. | | `did:dfos:request-proof` | [INTEGRATIONS](https://protocol.dfos.com/integrations#the-request-proof) | no | API request proofs: ride the `Authorization` header of a credential-gated API request and die with the freshness window. | | `did:dfos:identity-proof` | [INTEGRATIONS](https://protocol.dfos.com/integrations#the-identity-proof) | no | API identity proofs: bind one exact request to a bare DID, authentication only. | Names whose grammar is inseparable from their owner's machinery register there: credential resource forms in [CREDENTIALS](https://protocol.dfos.com/credentials); API action tokens, sign-in scope tokens, and app description members in [INTEGRATIONS](https://protocol.dfos.com/integrations); sign-request `payloadTyp` families in [RELAY](https://protocol.dfos.com/relay); and content schemas in [CONTENT-MODEL](https://protocol.dfos.com/content-model), hosted at [schemas.dfos.com](https://schemas.dfos.com). ### External registrations The corpus also mints names in two global namespaces DFOS does not own, and [RFC 8615 §3](https://www.rfc-editor.org/rfc/rfc8615#section-3) and [RFC 8552 §4.1.5](https://www.rfc-editor.org/rfc/rfc8552#section-4.1.5) require each to be entered in its IANA registry. A spec that mints a well-known path or an underscored name adds its row here in the same PR that specifies it; registration itself is an act of the project's stewards. | Name | Registry | Owner spec | Registered | | ----------------------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | ---------- | | `/.well-known/dfos-relay` | IANA Well-Known URIs ([RFC 8615](https://www.rfc-editor.org/rfc/rfc8615)) | [RELAY](https://protocol.dfos.com/relay#the-well-known-document) | not listed | | `/.well-known/dfos-app.json` | IANA Well-Known URIs ([RFC 8615](https://www.rfc-editor.org/rfc/rfc8615)) | [INTEGRATIONS](https://protocol.dfos.com/integrations#the-app-description-document) | not listed | | `/.well-known/dfos-did` | IANA Well-Known URIs ([RFC 8615](https://www.rfc-editor.org/rfc/rfc8615)) | [INTEGRATIONS](https://protocol.dfos.com/integrations#https-well-knowndfos-did) | not listed | | `/.well-known/dfos-key-proof` | IANA Well-Known URIs ([RFC 8615](https://www.rfc-editor.org/rfc/rfc8615)) | [INTEGRATIONS](https://protocol.dfos.com/integrations#key-ceremonies) | not listed | | `_dfos` (TXT) | IANA Underscored DNS Node Names ([RFC 8552](https://www.rfc-editor.org/rfc/rfc8552)) | [INTEGRATIONS](https://protocol.dfos.com/integrations#dns-txt-at-_dfosdomain) | not listed | --- ## Reference vectors The vectors in this section are published as [`packages/protocol-verify/vectors.json`](https://github.com/metalabel/dfos/blob/main/packages/protocol-verify/vectors.json), the artifact every verification suite loads. The prose here renders that artifact byte for byte; the TypeScript reference suite regenerates it from the fixed seeds and fails if the two diverge. All artifacts below are deterministic and reproducible from fixed seeds. An independent implementer can verify every value using standard Ed25519 + dag-cbor libraries. Private keys are derived from `SHA-256(UTF8("dfos-protocol-reference-key-N"))`. The same values are re-derived from scratch, in five languages using only native cryptography libraries, by the suites in [`packages/protocol-verify/`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify). Each suite hardcodes these constants and imports no DFOS library, so a disagreement between any two suites is an ambiguity in this document. ### Key 1 (Genesis Controller) ``` Seed: SHA-256("dfos-protocol-reference-key-1") Private key: 132d4bebdb6e62359afb930fe15d756a92ad96e6b0d47619988f5a1a55272aac Public key: ba421e272fad4f941c221e47f87d9253bdc04f7d4ad2625ae667ab9f0688ce32 Multikey: z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb Key ID: key_r9ev34fvc23z999veaaft83nn29zvhe ``` ### Key 2 (Rotated Controller) ``` Seed: SHA-256("dfos-protocol-reference-key-2") Private key: 384f5626906db84f6a773ec46475ff2d4458e92dd4dd13fe03dbb7510f4ca2a8 Public key: 0f350f994f94d675f04a325bd316ebedd740ca206eaaf609bdb641b5faa0f78c Multikey: z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK Key ID: key_ez9a874tckr3dv933d3ckdn7z6zrct8 ``` ### Identity chain: Create (Genesis) Operation: ```json { "version": 1, "type": "create", "authKeys": [ { "id": "key_r9ev34fvc23z999veaaft83nn29zvhe", "type": "Multikey", "publicKeyMultibase": "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" } ], "assertKeys": [ { "id": "key_r9ev34fvc23z999veaaft83nn29zvhe", "type": "Multikey", "publicKeyMultibase": "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" } ], "controllerKeys": [ { "id": "key_r9ev34fvc23z999veaaft83nn29zvhe", "type": "Multikey", "publicKeyMultibase": "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" } ], "createdAt": "2026-03-07T00:00:00.000Z" } ``` JWS Header: ```json { "alg": "EdDSA", "typ": "did:dfos:identity-op", "kid": "key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne" } ``` JWS Signature (hex): ``` 4dece71e7cebb4a3864ebd05ce40cbdb3fa5b8c5a701b297ae60db8be131830ff130f0a7630187391323c3e04cdbc7f44684e2ac801e0fb776d16e514ae1ae06 ``` JWS Token: ``` eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmlkZW50aXR5LW9wIiwia2lkIjoia2V5X3I5ZXYzNGZ2YzIzejk5OXZlYWFmdDgzbm4yOXp2aGUiLCJjaWQiOiJiYWZ5cmVpY29naHZqem52bGl1bG94eG1iZjU0dHB6cXdhaG5xcGlsazduY3hlcGppbmVkcGtnYTNuZSJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiY3JlYXRlIiwiYXV0aEtleXMiOlt7ImlkIjoia2V5X3I5ZXYzNGZ2YzIzejk5OXZlYWFmdDgzbm4yOXp2aGUiLCJ0eXBlIjoiTXVsdGlrZXkiLCJwdWJsaWNLZXlNdWx0aWJhc2UiOiJ6Nk1rcnpMTU53b0pTVjRQM1ljY1djYnRrOHZkOUx0Z01LbkxlYURMVXFMdUFTamIifV0sImFzc2VydEtleXMiOlt7ImlkIjoia2V5X3I5ZXYzNGZ2YzIzejk5OXZlYWFmdDgzbm4yOXp2aGUiLCJ0eXBlIjoiTXVsdGlrZXkiLCJwdWJsaWNLZXlNdWx0aWJhc2UiOiJ6Nk1rcnpMTU53b0pTVjRQM1ljY1djYnRrOHZkOUx0Z01LbkxlYURMVXFMdUFTamIifV0sImNvbnRyb2xsZXJLZXlzIjpbeyJpZCI6ImtleV9yOWV2MzRmdmMyM3o5OTl2ZWFhZnQ4M25uMjl6dmhlIiwidHlwZSI6Ik11bHRpa2V5IiwicHVibGljS2V5TXVsdGliYXNlIjoiejZNa3J6TE1Od29KU1Y0UDNZY2NXY2J0azh2ZDlMdGdNS25MZWFETFVxTHVBU2piIn1dLCJjcmVhdGVkQXQiOiIyMDI2LTAzLTA3VDAwOjAwOjAwLjAwMFoifQ.TeznHnzrtKOGTr0FzkDL2z-luMWnAbKXrmDbi-Exgw_xMPCnYwGHORMjw-BM28f0RoTirIAeD7d20W5RSuGuBg ``` Operation CID: ``` bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne ``` **Derived DID: `did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr`** ### Reference key proof (key 2's introduction) The rotation below introduces key 2 to all three roles, so it carries key 2's [key-proof envelope](#the-envelope) in `keyProofs` ([Key possession](#key-possession)). The envelope is signed by **key 2**, the key being introduced, while the operation carrying it is signed by **key 1**. Its deterministic inputs: `nonce` is the literal `dfos-protocol-reference-nonce-1`, `audience` is `keys.dfos.com`, `timestamp` is `2026-03-07T00:00:30.000Z`. The rest is derived: `did` is the reference DID, `roleSet` is `auth,assert,controller`, `prevCID` is the genesis CID, `publicKeyMultibase` is key 2's multikey. Decoded payload: ```json { "nonce": "dfos-protocol-reference-nonce-1", "audience": "keys.dfos.com", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "roleSet": "auth,assert,controller", "prevCID": "bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne", "publicKeyMultibase": "z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK", "timestamp": "2026-03-07T00:00:30.000Z" } ``` Canonical signing input (the exact payload octets): ``` {"nonce":"dfos-protocol-reference-nonce-1","audience":"keys.dfos.com","did":"did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr","roleSet":"auth,assert,controller","prevCID":"bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne","publicKeyMultibase":"z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK","timestamp":"2026-03-07T00:00:30.000Z"} ``` JWS protected header: ```json { "alg": "EdDSA", "typ": "did:dfos:key-add" } ``` JWS Signature (hex): ``` f27835d076dbb360450ac6284983474b54f4d36d3e1d79b8683d7442b1e5a72b0ed6caa6cb553f763f72133d0225230260dc214d931b4e26a7849f021f52ed03 ``` Compact JWS token: ``` eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmtleS1hZGQifQ.eyJub25jZSI6ImRmb3MtcHJvdG9jb2wtcmVmZXJlbmNlLW5vbmNlLTEiLCJhdWRpZW5jZSI6ImtleXMuZGZvcy5jb20iLCJkaWQiOiJkaWQ6ZGZvczpjbm5uZnQ5ZjhhMnJuOTM4ZDZua3ozOHI4NDd2MmtyIiwicm9sZVNldCI6ImF1dGgsYXNzZXJ0LGNvbnRyb2xsZXIiLCJwcmV2Q0lEIjoiYmFmeXJlaWNvZ2h2anpudmxpdWxveHhtYmY1NHRwenF3YWhucXBpbGs3bmN4ZXBqaW5lZHBrZ2EzbmUiLCJwdWJsaWNLZXlNdWx0aWJhc2UiOiJ6Nk1rZlVkNjVKckFoZmRnRnVNQ2NjVTlUaFF2akIyZkpBTVVIa3V1YWpGOTkyZ0siLCJ0aW1lc3RhbXAiOiIyMDI2LTAzLTA3VDAwOjAwOjMwLjAwMFoifQ.8ng10Hbbs2BFCsYoSYNHS1T0020-HXm4aD10QrHlpysO1sqmy1U_dj9yEz0CJSMCYNwhTZMbTianhJ8CH1LtAw ``` ### Identity chain: Update (Key Rotation) JWS Header: ```json { "alg": "EdDSA", "typ": "did:dfos:identity-op", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyreiarc7mv6fvhaoe2mmk4ujpskgqpesv66pzd5juqlg5bzmridikkqy" } ``` Operation: ```json { "version": 1, "type": "update", "previousOperationCID": "bafyreicoghvjznvliuloxxmbf54tpzqwahnqpilk7ncxepjinedpkga3ne", "authKeys": [ { "id": "key_ez9a874tckr3dv933d3ckdn7z6zrct8", "type": "Multikey", "publicKeyMultibase": "z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK" } ], "assertKeys": [ { "id": "key_ez9a874tckr3dv933d3ckdn7z6zrct8", "type": "Multikey", "publicKeyMultibase": "z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK" } ], "controllerKeys": [ { "id": "key_ez9a874tckr3dv933d3ckdn7z6zrct8", "type": "Multikey", "publicKeyMultibase": "z6MkfUd65JrAhfdgFuMCccU9ThQvjB2fJAMUHkuuajF992gK" } ], "createdAt": "2026-03-07T00:01:00.000Z", "keyProofs": [ "eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmtleS1hZGQifQ.eyJub25jZSI6ImRmb3MtcHJvdG9jb2wtcmVmZXJlbmNlLW5vbmNlLTEiLCJhdWRpZW5jZSI6ImtleXMuZGZvcy5jb20iLCJkaWQiOiJkaWQ6ZGZvczpjbm5uZnQ5ZjhhMnJuOTM4ZDZua3ozOHI4NDd2MmtyIiwicm9sZVNldCI6ImF1dGgsYXNzZXJ0LGNvbnRyb2xsZXIiLCJwcmV2Q0lEIjoiYmFmeXJlaWNvZ2h2anpudmxpdWxveHhtYmY1NHRwenF3YWhucXBpbGs3bmN4ZXBqaW5lZHBrZ2EzbmUiLCJwdWJsaWNLZXlNdWx0aWJhc2UiOiJ6Nk1rZlVkNjVKckFoZmRnRnVNQ2NjVTlUaFF2akIyZkpBTVVIa3V1YWpGOTkyZ0siLCJ0aW1lc3RhbXAiOiIyMDI2LTAzLTA3VDAwOjAwOjMwLjAwMFoifQ.8ng10Hbbs2BFCsYoSYNHS1T0020-HXm4aD10QrHlpysO1sqmy1U_dj9yEz0CJSMCYNwhTZMbTianhJ8CH1LtAw" ] } ``` JWS Signature (hex): ``` d77a2bfd7ef30ce7b391214775604bc0f70dc88686dd794701bf661e9806f3354395dcf4c063f45fc5a25481cf2d0fb6d19b023afb21f18e816e0c5d6c8bcc04 ``` JWS Token: ``` eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmlkZW50aXR5LW9wIiwia2lkIjoiZGlkOmRmb3M6Y25ubmZ0OWY4YTJybjkzOGQ2bmt6MzhyODQ3djJrciNrZXlfcjlldjM0ZnZjMjN6OTk5dmVhYWZ0ODNubjI5enZoZSIsImNpZCI6ImJhZnlyZWlhcmM3bXY2ZnZoYW9lMm1tazR1anBza2dxcGVzdjY2cHpkNWp1cWxnNWJ6bXJpZGlra3F5In0.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoidXBkYXRlIiwicHJldmlvdXNPcGVyYXRpb25DSUQiOiJiYWZ5cmVpY29naHZqem52bGl1bG94eG1iZjU0dHB6cXdhaG5xcGlsazduY3hlcGppbmVkcGtnYTNuZSIsImF1dGhLZXlzIjpbeyJpZCI6ImtleV9lejlhODc0dGNrcjNkdjkzM2QzY2tkbjd6NnpyY3Q4IiwidHlwZSI6Ik11bHRpa2V5IiwicHVibGljS2V5TXVsdGliYXNlIjoiejZNa2ZVZDY1SnJBaGZkZ0Z1TUNjY1U5VGhRdmpCMmZKQU1VSGt1dWFqRjk5MmdLIn1dLCJhc3NlcnRLZXlzIjpbeyJpZCI6ImtleV9lejlhODc0dGNrcjNkdjkzM2QzY2tkbjd6NnpyY3Q4IiwidHlwZSI6Ik11bHRpa2V5IiwicHVibGljS2V5TXVsdGliYXNlIjoiejZNa2ZVZDY1SnJBaGZkZ0Z1TUNjY1U5VGhRdmpCMmZKQU1VSGt1dWFqRjk5MmdLIn1dLCJjb250cm9sbGVyS2V5cyI6W3siaWQiOiJrZXlfZXo5YTg3NHRja3IzZHY5MzNkM2NrZG43ejZ6cmN0OCIsInR5cGUiOiJNdWx0aWtleSIsInB1YmxpY0tleU11bHRpYmFzZSI6Ino2TWtmVWQ2NUpyQWhmZGdGdU1DY2NVOVRoUXZqQjJmSkFNVUhrdXVhakY5OTJnSyJ9XSwiY3JlYXRlZEF0IjoiMjAyNi0wMy0wN1QwMDowMTowMC4wMDBaIiwia2V5UHJvb2ZzIjpbImV5SmhiR2NpT2lKRlpFUlRRU0lzSW5SNWNDSTZJbVJwWkRwa1ptOXpPbXRsZVMxaFpHUWlmUS5leUp1YjI1alpTSTZJbVJtYjNNdGNISnZkRzlqYjJ3dGNtVm1aWEpsYm1ObExXNXZibU5sTFRFaUxDSmhkV1JwWlc1alpTSTZJbXRsZVhNdVpHWnZjeTVqYjIwaUxDSmthV1FpT2lKa2FXUTZaR1p2Y3pwamJtNXVablE1WmpoaE1uSnVPVE00WkRadWEzb3pPSEk0TkRkMk1tdHlJaXdpY205c1pWTmxkQ0k2SW1GMWRHZ3NZWE56WlhKMExHTnZiblJ5YjJ4c1pYSWlMQ0p3Y21WMlEwbEVJam9pWW1GbWVYSmxhV052WjJoMmFucHVkbXhwZFd4dmVIaHRZbVkxTkhSd2VuRjNZV2h1Y1hCcGJHczNibU40WlhCcWFXNWxaSEJyWjJFemJtVWlMQ0p3ZFdKc2FXTkxaWGxOZFd4MGFXSmhjMlVpT2lKNk5rMXJabFZrTmpWS2NrRm9abVJuUm5WTlEyTmpWVGxVYUZGMmFrSXlaa3BCVFZWSWEzVjFZV3BHT1RreVowc2lMQ0owYVcxbGMzUmhiWEFpT2lJeU1ESTJMVEF6TFRBM1ZEQXdPakF3T2pNd0xqQXdNRm9pZlEuOG5nMTBIYmJzMkJGQ3NZb1NZTkhTMVQwMDIwLUhYbTRhRDEwUXJIbHB5c08xc3FteTFVX2RqOXlFejBDSlNNQ1lOd2hUWk1iVGlhbmhKOENIMUx0QXciXX0.13or_X7zDOezkSFHdWBLwPcNyIaG3XlHAb9mHpgG8zVDldz0wGP0X8WiVIHPLQ-20ZsCOvsh8Y6BbgxdbIvMBA ``` Operation CID: ``` bafyreiarc7mv6fvhaoe2mmk4ujpskgqpesv66pzd5juqlg5bzmridikkqy ``` Post-rotation: DID unchanged (`did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr`), controller rotated to `key_ez9a874tckr3dv933d3ckdn7z6zrct8`, key 2 proved for all three roles by the embedded envelope. ### Identity chain: Delete + Restore Delete Operation: ```json { "version": 1, "type": "delete", "previousOperationCID": "bafyreiarc7mv6fvhaoe2mmk4ujpskgqpesv66pzd5juqlg5bzmridikkqy", "createdAt": "2026-03-07T00:02:00.000Z" } ``` Delete JWS Header: ```json { "alg": "EdDSA", "typ": "did:dfos:identity-op", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_ez9a874tckr3dv933d3ckdn7z6zrct8", "cid": "bafyreiaiy5m4fiyntdryikzfwzynojwiglkqrwiefulb4dl36eqeefbpwm" } ``` Delete JWS Signature (hex): ``` 40887e1d10fe60457ce328355f72f0cfeb5724418f08baee4ecb160ba2206f98ff406d1a18f8c9eac02a144d5533c29445896616468b818655e8edbcdf560108 ``` Delete JWS Token: ``` eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmlkZW50aXR5LW9wIiwia2lkIjoiZGlkOmRmb3M6Y25ubmZ0OWY4YTJybjkzOGQ2bmt6MzhyODQ3djJrciNrZXlfZXo5YTg3NHRja3IzZHY5MzNkM2NrZG43ejZ6cmN0OCIsImNpZCI6ImJhZnlyZWlhaXk1bTRmaXludGRyeWlremZ3enlub2p3aWdsa3Fyd2llZnVsYjRkbDM2ZXFlZWZicHdtIn0.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiZGVsZXRlIiwicHJldmlvdXNPcGVyYXRpb25DSUQiOiJiYWZ5cmVpYXJjN212NmZ2aGFvZTJtbWs0dWpwc2tncXBlc3Y2NnB6ZDVqdXFsZzViem1yaWRpa2txeSIsImNyZWF0ZWRBdCI6IjIwMjYtMDMtMDdUMDA6MDI6MDAuMDAwWiJ9.QIh-HRD-YEV84yg1X3Lwz-tXJEGPCLruTssWC6Igb5j_QG0aGPjJ6sAqFE1VM8KURYlmFkaLgYZV6O2831YBCA ``` Delete Operation CID: ``` bafyreiaiy5m4fiyntdryikzfwzynojwiglkqrwiefulb4dl36eqeefbpwm ``` Restore Operation: ```json { "version": 1, "type": "restore", "previousOperationCID": "bafyreiaiy5m4fiyntdryikzfwzynojwiglkqrwiefulb4dl36eqeefbpwm", "createdAt": "2026-03-07T00:03:00.000Z" } ``` Restore JWS Header: ```json { "alg": "EdDSA", "typ": "did:dfos:identity-op", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_ez9a874tckr3dv933d3ckdn7z6zrct8", "cid": "bafyreicfxp65m3js4tellb3optwn54ginqv7pp4ldifcnuvry7glunh7aq" } ``` Restore JWS Signature (hex): ``` 2622005ca66a2199c3a5952b6dde12ec5eed1280448c478829c1a0dd645e6170052628bdeb4c2964b15fcab737c8028e36c330f614f9685462be8b3892d86e04 ``` Restore JWS Token: ``` eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmlkZW50aXR5LW9wIiwia2lkIjoiZGlkOmRmb3M6Y25ubmZ0OWY4YTJybjkzOGQ2bmt6MzhyODQ3djJrciNrZXlfZXo5YTg3NHRja3IzZHY5MzNkM2NrZG43ejZ6cmN0OCIsImNpZCI6ImJhZnlyZWljZnhwNjVtM2pzNHRlbGxiM29wdHduNTRnaW5xdjdwcDRsZGlmY251dnJ5N2dsdW5oN2FxIn0.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoicmVzdG9yZSIsInByZXZpb3VzT3BlcmF0aW9uQ0lEIjoiYmFmeXJlaWFpeTVtNGZpeW50ZHJ5aWt6Znd6eW5vandpZ2xrcXJ3aWVmdWxiNGRsMzZlcWVlZmJwd20iLCJjcmVhdGVkQXQiOiIyMDI2LTAzLTA3VDAwOjAzOjAwLjAwMFoifQ.JiIAXKZqIZnDpZUrbd4S7F7tEoBEjEeIKcGg3WReYXAFJii960wpZLFfyrc3yAKONsMw9hT5aFRivos4kthuBA ``` Restore Operation CID: ``` bafyreicfxp65m3js4tellb3optwn54ginqv7pp4ldifcnuvry7glunh7aq ``` ### Content chain: Document + Create Document (flat content object): ```json { "$schema": "https://schemas.dfos.com/post/v1", "format": "short-post", "publishedAt": "2026-03-07T00:02:00.000Z", "title": "Hello World", "body": "First post on the protocol.", "credits": [ { "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "label": "author" } ] } ``` Document CID: ``` bafyreie6xfkrtwax2dq5gdw3rpsurz2glsduxycfhk7jjllewiwivkkafu ``` Content Create JWS Header: ```json { "alg": "EdDSA", "typ": "did:dfos:content-op", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_ez9a874tckr3dv933d3ckdn7z6zrct8", "cid": "bafyreibs3vlvainfjfuet6x4uds3pivbmbohy7f64iegbuw3gpsuqtma6i" } ``` Content Create Payload: ```json { "version": 1, "type": "create", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "documentCID": "bafyreie6xfkrtwax2dq5gdw3rpsurz2glsduxycfhk7jjllewiwivkkafu", "baseDocumentCID": null, "createdAt": "2026-03-07T00:02:00.000Z" } ``` Content Create JWS Signature (hex): ``` 069523331dffceae6af9bc4e40dd29978b5f81ffee648b7deebedf33f76e909afefb307483bb311bd21c06ccb4451dcffc3b482b8181d7b8a4c035030c79ee03 ``` Content Create JWS Token: ``` eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmNvbnRlbnQtb3AiLCJraWQiOiJkaWQ6ZGZvczpjbm5uZnQ5ZjhhMnJuOTM4ZDZua3ozOHI4NDd2MmtyI2tleV9lejlhODc0dGNrcjNkdjkzM2QzY2tkbjd6NnpyY3Q4IiwiY2lkIjoiYmFmeXJlaWJzM3ZsdmFpbmZqZnVldDZ4NHVkczNwaXZibWJvaHk3ZjY0aWVnYnV3M2dwc3VxdG1hNmkifQ.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiY3JlYXRlIiwiZGlkIjoiZGlkOmRmb3M6Y25ubmZ0OWY4YTJybjkzOGQ2bmt6MzhyODQ3djJrciIsImRvY3VtZW50Q0lEIjoiYmFmeXJlaWU2eGZrcnR3YXgyZHE1Z2R3M3Jwc3VyejJnbHNkdXh5Y2ZoazdqamxsZXdpd2l2a2thZnUiLCJiYXNlRG9jdW1lbnRDSUQiOm51bGwsImNyZWF0ZWRBdCI6IjIwMjYtMDMtMDdUMDA6MDI6MDAuMDAwWiJ9.BpUjMx3_zq5q-bxOQN0pl4tfgf_uZIt97r7fM_dukJr--zB0g7sxG9IcBsy0RR3P_DtIK4GB17ikwDUDDHnuAw ``` Content Operation CID: ``` bafyreibs3vlvainfjfuet6x4uds3pivbmbohy7f64iegbuw3gpsuqtma6i ``` ### Content chain: Update Content Update Payload: ```json { "version": 1, "type": "update", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "previousOperationCID": "bafyreibs3vlvainfjfuet6x4uds3pivbmbohy7f64iegbuw3gpsuqtma6i", "documentCID": "bafyreiaoinzo2ai4hx56b7244zahnfqmgurcd3rppqbawhv32xzlvct5m4", "baseDocumentCID": "bafyreie6xfkrtwax2dq5gdw3rpsurz2glsduxycfhk7jjllewiwivkkafu", "createdAt": "2026-03-07T00:03:00.000Z" } ``` Updated document (flat content object): ```json { "$schema": "https://schemas.dfos.com/post/v1", "format": "short-post", "publishedAt": "2026-03-07T00:02:00.000Z", "title": "Hello World (edited)", "body": "Updated content.", "credits": [ { "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "label": "author" } ] } ``` Document CID (edited): ``` bafyreiaoinzo2ai4hx56b7244zahnfqmgurcd3rppqbawhv32xzlvct5m4 ``` Content Update CID: ``` bafyreied5cjgjjt2pdz52k6pgipcjg3i4xl7txbrbdedscejvqhtgltxdi ``` ### Content chain verified state ``` Content ID: 8n8fnzhrrefkrde6h72kfvff43r8c63 Genesis CID: bafyreibs3vlvainfjfuet6x4uds3pivbmbohy7f64iegbuw3gpsuqtma6i Head CID: bafyreied5cjgjjt2pdz52k6pgipcjg3i4xl7txbrbdedscejvqhtgltxdi ``` --- ## Independent verification The checklist is executable. The suites in [`packages/protocol-verify/`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify) load [`vectors.json`](https://github.com/metalabel/dfos/blob/main/packages/protocol-verify/vectors.json) and re-derive every value above in five languages: multikey decoding, JWS verification, CID and DID derivation, the rotation's key proof, dag-cbor number encoding, and the credential and delegated-chain examples in [`packages/dfos-protocol/examples/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/examples). --- ## Source The reference TypeScript implementation is [`packages/dfos-protocol/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol), self-contained with zero monorepo dependencies, published as [`@metalabel/dfos-protocol`](https://www.npmjs.com/package/@metalabel/dfos-protocol). Its Go twin is [`packages/dfos-protocol-go/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol-go). The five-language cross-verification suites are [`packages/protocol-verify/`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify). ### Related specifications - [DID Method: `did:dfos`](https://protocol.dfos.com/did-method): the W3C DID method registration for identity chains - [Content Model](https://protocol.dfos.com/content-model): document schemas and the credit vocabulary - [Credentials](https://protocol.dfos.com/credentials): authorization credentials and revocation - [Relay](https://protocol.dfos.com/relay): the HTTP relay: read and write contracts, ingestion, profiles, and the content plane - [Integrations](https://protocol.dfos.com/integrations): sign-in, API authentication, origin binding, and key ceremonies - [Guarantees](https://protocol.dfos.com/guarantees): what holds without trusting a server, what is a chosen view, and what the operator can read --- # DID Method: `did:dfos` W3C DID method registration for DFOS identity chains. Ed25519 keys, self-certifying identifiers, resolution from a signed chain rather than a registry. [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol) · [Protocol Specification](https://protocol.dfos.com/spec) · [npm](https://www.npmjs.com/package/@metalabel/dfos-protocol) --- ## 1. Scope This document registers `did:dfos`. It defines the identifier syntax, the DID Document built from an identity's verified state, and the mapping of DID operations onto identity chain operations. The mechanics underneath are specified in [PROTOCOL.md](https://protocol.dfos.com/spec) and are not restated here: canonical encoding and CIDs, the JWS envelope, identity chain signer validity, key possession, services, and the verification algorithm. The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). --- ## 2. Method Name The method name is `dfos`. A DID using this method MUST begin with the prefix `did:dfos:`. --- ## 3. Method-Specific Identifier The method-specific identifier is a 31-character string derived from the genesis operation CID of an identity chain. ### 3.1 ABNF ```abnf dfos-did = "did:dfos:" dfos-id dfos-id = 31dfos-char dfos-char = "2" / "3" / "4" / "6" / "7" / "8" / "9" / "a" / "c" / "d" / "e" / "f" / "h" / "k" / "n" / "r" / "t" / "v" / "z" ``` The alphabet is 19 characters: `2346789acdefhknrtvz`. The identifier is exactly 31 characters, an identifier space of `19^31 ≈ 2^131.6`. An identifier that does not match `dfos-id`, the exact 31-character form over this alphabet, is not a valid `did:dfos` identifier. Resolvers and verifiers MUST reject any operation that references, and any resolved state that yields, a `did:dfos` identifier of any other length or character set. ### 3.2 Derivation The method-specific identifier is derived deterministically from the genesis identity operation: ``` 1. Construct the genesis identity operation payload (type: "create") 2. Canonical-encode the payload as dag-cbor → CBOR bytes 3. Hash: SHA-256(CBOR bytes) → 32-byte digest 4. Construct CIDv1: [0x01, 0x71, 0x12, 0x20, ...32 digest bytes] → CID bytes 5. Hash the CID: SHA-256(CID bytes) → 32-byte digest 6. Encode: for each of the first 31 bytes → alphabet[byte % 19] ``` The resulting 31-character string is the method-specific identifier. The full DID is `did:dfos:` prepended to this string. ### 3.3 Example ``` Genesis CID bytes (hex): 017112204e31ea9cb6ab4516ebdd812f7937e61601db07a16afb45723d286906f5181b69 SHA-256 of CID bytes: c66d21f27dceea0b05534c225ad7018ac7d4dfded0609dcd18022a3739a5488c First 31 bytes encoded: cnnnft9f8a2rn938d6nkz38r847v2kr DID: did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr ``` The worked example with key material, CBOR bytes, and CID construction is in [PROTOCOL.md](https://protocol.dfos.com/spec). --- ## 4. DID Document A resolved `did:dfos` DID Document is constructed from the identity's **effective** state: the key state and services after the chain's last operation, with every key membership no possession proof covers excluded ([PROTOCOL.md → Key Possession](https://protocol.dfos.com/spec#key-possession)). A void membership is never a verification method. The document states the keys the chain proved, not the keys it listed. ### 4.1 DID Document Structure ```json { "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], "id": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "controller": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "verificationMethod": [ { "id": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "type": "Multikey", "controller": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "publicKeyMultibase": "z6MkrzLMNwoJSV4P3YccWcbtk8vd9LtgMKnLeaDLUqLuASjb" } ], "authentication": [ "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe" ], "assertionMethod": [ "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe" ], "capabilityInvocation": [ "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe" ] } ``` ### 4.2 Verification Method Mapping Identity chain operations declare three key sets. They map to W3C verification relationships as follows: | Identity Chain Key Set | W3C Verification Relationship | Purpose | | ---------------------- | ----------------------------- | -------------------------------------------------------------------- | | `authKeys` | `authentication` | Prove control of the DID (e.g., login, session establishment) | | `assertKeys` | `assertionMethod` | Issue verifiable assertions (e.g., sign content chain operations) | | `controllerKeys` | `capabilityInvocation` | Manage the DID itself (sign identity chain update/delete operations) | Each effective key becomes a `verificationMethod` entry. The `id` is the DID URL `did:dfos:#`. The `type` is `Multikey`. The `publicKeyMultibase` is the W3C Multikey encoding (multicodec `0xed01` prefix + base58btc + `z` multibase prefix). Key ids MUST be unique within a single key set on any operation that declares key sets (`create` or `update`); a verifier rejects a repeated id in the same usage section. The same key id MAY appear across different sets, and that is the common case: one key serving authentication, assertion, and control at once, as in the document above. Verification methods are keyed by DID URL, so a key id in several roles yields one `verificationMethod` entry referenced from each relationship. ### 4.3 Controller The `controller` property is always the DID itself. Only keys in the identity chain's `controllerKeys` set sign operations that modify the chain. ### 4.4 Key Rotation The DID Document reflects the state after the last operation of the chain, never a union across history. Rotated-out keys are absent from the document. Historical key states are recovered by walking the chain. ### 4.5 Services Identity chain `create` and `update` operations MAY carry a controller-signed `services` array, the identity's discovery vocabulary, defined in [PROTOCOL.md → Services](https://protocol.dfos.com/spec#services). Each entry in the effective state projects into the DID Document `service` array. The entry `id` becomes the DID URL fragment (`did:dfos:#`). The namespace is open: a type this table does not name is preserved verbatim with its `id` re-anchored, and otherwise ignored. | Service `type` | Fields | DID Document mapping | | ------------------------- | ----------------- | -------------------------------------------------------------- | | `DfosRelay` | `endpoint` (URL) | `serviceEndpoint` = the relay URL | | `DfosAuthorizationServer` | `endpoint` (URL) | `serviceEndpoint` = the authorize origin | | `ContentAnchor` | `label`, `anchor` | `serviceEndpoint` = the anchor; `label` retained as a property | ```json "service": [ { "id": "did:dfos:zhkrrzrd7z623ha8tt7dt699de8r3ar#relay", "type": "DfosRelay", "serviceEndpoint": "https://relay.dfos.com" }, { "id": "did:dfos:zhkrrzrd7z623ha8tt7dt699de8r3ar#profile", "type": "ContentAnchor", "label": "profile", "serviceEndpoint": "cv7n8vkvr64cctf3294h9k4eanhff8z" } ] ``` --- ## 5. Operations Each DID operation is one identity chain operation. The payload schemas, signer rules, and validity checks are in [PROTOCOL.md → Identity Operations](https://protocol.dfos.com/spec#identity-operations). ### 5.1 Create Creating a `did:dfos` identifier means constructing and signing a genesis identity chain operation. 1. Generate one Ed25519 key pair. 2. Construct the genesis payload with `type: "create"`, declaring that one key as the sole entry of `authKeys`, `assertKeys`, and `controllerKeys`. Genesis declares exactly one key and its signature is that key's possession proof. Further keys join via `update` operations under the possession rule. 3. Sign the operation per PROTOCOL's JWS envelope. 4. The DID is derived from the genesis CID as in [Section 3.2](#32-derivation). ### 5.2 Read (Resolve) Resolving a `did:dfos` DID means obtaining the identity chain and constructing a DID Document from its effective state. #### 5.2.1 Resolution Algorithm Given a DID `did:dfos:`: 1. **Obtain** the identity chain. This method does not prescribe how chains are transported. 2. **Verify** the chain per [PROTOCOL.md → Verification](https://protocol.dfos.com/spec#verification): the genesis bootstrap, JWS signatures against the declared controller state, `previousOperationCID` linkage, `createdAt` ordering, `header.cid` consistency, and key possession. 3. **Bind** the chain to the DID: derive the genesis operation CID, and verify that `SHA-256(genesis CID bytes)` encoded with the ID alphabet produces ``. If it does not match, the chain does not belong to this DID and the resolver MUST reject it. 4. **Construct** the DID Document from the effective state using the mapping in [Section 4](#4-did-document). #### 5.2.2 Resolution Result A resolver returns the DID Document with two metadata objects: | Object | Property | Value | | ----------------------- | ---------------- | -------------------------------------------------------- | | `didResolutionMetadata` | `contentType` | `application/did+ld+json` | | `didDocumentMetadata` | `created` | `createdAt` from the genesis operation | | `didDocumentMetadata` | `updated` | `createdAt` from the most recent operation | | `didDocumentMetadata` | `deactivated` | `true` if the chain's last operation is `type: "delete"` | | `didDocumentMetadata` | `operationCount` | Number of operations in the chain | #### 5.2.3 Self-Certification Step 3 above is the self-certification check: it proves the chain belongs to the claimed DID using only the chain content and a hash function. A resolver therefore does not trust the server or peer that supplied the chain, the same chain served by independent sources yields the same document, and a chain held locally resolves offline. #### 5.2.4 Resolution Is Relay-Relative Authorship is verifiable without trusting any server. Which view of an identity you follow is a choice of relay. A relay keeps its own identity log linear by first-seen admission: it admits the first operation it sees at a chain position and does not admit a second at that position. Two relays that admitted different operations at the same position hold two views of the same identity. Both views are controller-signed, and neither is invalid. Which relay you resolve through is which view you get. A resolver that needs one answer picks one relay; an identity's `DfosRelay` service entries name relays that serve it. A resolver reading several relays sees the divergence and can present both views. The chain itself is transport-agnostic: any source that delivers the ordered JWS tokens is a valid one, including an HTTP relay, a peer exchange, a local file, or an exported bundle. A relay serving the DIF Universal Resolver binding answers at `GET /1.0/identifiers/:did`; the route contract is in [RELAY.md](https://protocol.dfos.com/relay#the-read-contract). ### 5.3 Update Updating a `did:dfos` DID means appending a signed `update` operation: `type: "update"`, the new key sets, a `keyProofs` envelope for each key the operation introduces, and `previousOperationCID` set to the CID of the current chain tip, signed with a key from the current `controllerKeys` set. The DID does not change. The resolved document reflects the new key sets. ### 5.4 Deactivate (Delete) Deactivating a `did:dfos` DID means appending a signed `delete` operation, `previousOperationCID` set to the CID of the current chain tip, signed with a key from the current `controllerKeys` set. After deactivation: - Resolution MUST return `deactivated: true` in the document metadata, and the DID Document SHOULD contain an empty set of verification methods. The reference relays return an empty `verificationMethod` array and omit the verification relationships and services. - The `delete` operation is a permanent, auditable fact of the log. It is replicated and retained like any other operation. - The chain is sealed against every operation except one: a `restore` in the immediate successor position ([Section 5.5](#55-restore-undelete)). Any other operation from the deleted head is rejected. A `restore` needs only a controller key of the deleted state, so deleting an identity is not a substitute for rotating out a compromised key. Rotate first, then delete. ### 5.5 Restore (Undelete) Restoring a deactivated DID means appending a signed `restore` operation immediately after the `delete`: `type: "restore"` and `previousOperationCID` set to the CID of the `delete`, carrying nothing else beyond `version` and `createdAt`. It is signed with a controller key of the deleted head state, the key state the `delete` produced, which carries the last key sets unchanged. A key rotated out before the delete is not in that state and cannot restore. A valid `restore` clears the deactivated state: resolution reports `deactivated: false` and the document reflects the keys and services as of the delete, verbatim. Later changes happen via ordinary `update` operations. `restore` is valid only in the successor-of-delete position. Deactivation is reversible by a controller key and only by a controller key. Both the `delete` and the `restore` stay in the log, so the deactivation history is auditable. There is no seal that a controller key cannot reopen. --- ## 6. Security Considerations ### 6.1 Self-Certifying Identifiers The identifier is a hash of the genesis operation content, and resolution verifies that binding ([Section 5.2.3](#523-self-certification)). A chain that does not derive the claimed identifier is rejected. The identifier is 31 characters over a 19-symbol alphabet: ``` Identifier space: 19^31 ≈ 2^131.6 Birthday collision: ≈ 2^65.8 Targeted second-preimage: ≈ 2^131.6 ``` These bound the identifier only. Finding two genesis chains that encode to the same DID costs `≈ 2^65.8`; forging a chain that encodes to a specific victim DID costs `≈ 2^131.6`. The 32-byte genesis CID and the Ed25519 signatures are unaffected by the truncation. ### 6.2 Key Compromise A holder whose controller key is compromised signs an `update` removing that key. There is no key pre-rotation: nothing pre-commits to a future key. The window between compromise and rotation is open. Each role set holds up to 256 keys and any one current key in a set authorizes an operation, so an identity can hold keys on several devices and a lost device is not loss of the identity. This is availability, not recovery: the extra keys are registered in advance, while a controller key is still held, and each one is another key to keep safe. ### 6.3 Who Can Extend a Chain Every operation is signed by a controller key in the chain's declared state. Whoever holds a controller key can update, delete, and restore the identity. Where custody is split, for example an identity whose controller keys include one held by a hosting platform, each holder has that full power independently. What no holder has is silence. Every extension is a signed operation in the log, addressed by CID and replicated by relays that have it, so a key removal or a deletion is visible to anyone reading the chain. An identity chain has no consensus layer. Two operations signed at the same chain position are a divergence between relay views, handled as in [Section 5.2.4](#524-resolution-is-relay-relative), not a proof of invalidity. ### 6.4 Transport Security This method mandates no transport security. Resolution verifies the chain against the DID rather than the source, so an intercepting attacker can withhold the chain, serve a stale chain, or serve a different chain that fails the self-certification check. It cannot produce a modified or forged chain that passes. A resolver that depends on a single source can be denied service by that source. Applications SHOULD support several chain sources and MAY cache verified chains locally. ### 6.5 Algorithms Ed25519 is the only signature algorithm. Implementations MUST reject operations signed with any other algorithm. --- ## 7. Privacy Considerations ### 7.1 Correlation `did:dfos` identifiers are persistent and globally unique, so every content chain a DID signs is correlatable to it. Unlinkability across contexts comes from using distinct identities, each its own chain and DID. ### 7.2 Key Material Identity chains carry public keys only. Private key material is never in the chain and MUST NOT be transmitted during resolution. ### 7.3 Chain History The full chain is available to any resolver, including the history of key rotations and their timestamps. That metadata is public by construction. ### 7.4 Resolution Privacy A resolver reading a local chain reveals nothing. A resolver reading a relay reveals to that operator which DIDs it is interested in. Applications with strong privacy requirements SHOULD resolve from local caches or through a privacy-preserving transport. --- ## 8. References ### 8.1 Normative References | Reference | URI | | --------------------------- | --------------------------------------------------- | | W3C DID Core 1.0 | https://www.w3.org/TR/did-core/ | | W3C Multikey | https://www.w3.org/TR/controller-document/#multikey | | RFC 2119 (Key Words) | https://www.rfc-editor.org/rfc/rfc2119 | | RFC 7515 (JWS) | https://www.rfc-editor.org/rfc/rfc7515 | | RFC 8032 (Ed25519) | https://www.rfc-editor.org/rfc/rfc8032 | | DFOS Protocol Specification | https://protocol.dfos.com/spec | ### 8.2 Informative References | Reference | URI | | --------------------------- | -------------------------------------------------------------------- | | W3C DID Spec Registries | https://w3c.github.io/did-spec-registries/ | | Multicodec Table | https://github.com/multiformats/multicodec | | CIDv1 Specification | https://github.com/multiformats/cid | | dag-cbor Codec | https://ipld.io/specs/codecs/dag-cbor/spec/ | | Reference implementation | https://www.npmjs.com/package/@metalabel/dfos-protocol | | Cross-language verification | https://github.com/metalabel/dfos/tree/main/packages/protocol-verify | | DFOS Guarantees | https://protocol.dfos.com/guarantees | --- # DFOS Credentials Authorization credentials for the DFOS protocol: CID-addressable JWS tokens that carry a resource grant, a linear delegation chain, and an expiry. They answer one question, "does this DID have permission to do this thing?", and they are the artifact a non-creator presents to write a content chain or to read a gated resource. The shape is [UCAN](https://github.com/ucan-wg/spec) 0.10's: a compact-JWS token whose payload carries `att` attenuations and a `prf` proof chain. Three deltas from UCAN are deliberate. `prf` carries at most one parent, so delegation is linear. There is no `nnc`, `fct`, or `nbf` member: a credential is a standing grant, request binding is the API request proof's job, and `exp` against the basis time is the whole temporal window. Revocation is a first-class signed artifact on the proof plane. [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/src/credentials) · [npm](https://www.npmjs.com/package/@metalabel/dfos-protocol) --- ## Overview Two mechanisms make credentials composable: 1. **Delegation chains.** A credential embeds its parent credential in its `prf` field, forming a verifiable linear chain of authority from a root issuer down to the leaf holder. 2. **Monotonic attenuation.** Each hop in a delegation chain narrows scope and never widens it: fewer resources, fewer actions, shorter expiry. Credentials are content-addressed via CID, using the same `dagCborCanonicalEncode` plus SHA-256 scheme as all protocol objects. The CID is carried in the JWS header, which makes each credential a stable artifact and gives revocation an address. Every credential is verified against a **basis time**, defined once in [PROTOCOL, Time basis](https://protocol.dfos.com/spec#time-basis): the operation's own `createdAt` for a credential carried inline in a committed operation, and now for a credential presented at read time. Every temporal and key-resolution rule in this document resolves at that basis. Signature checking follows [PROTOCOL, Signature verification profile](https://protocol.dfos.com/spec#signature-verification-profile), which is normative for credentials and revocations and is not restated here. --- ## Schema ### DFOSCredentialPayload The credential payload is validated against the schema below. Unknown top-level fields are preserved and ignored rather than rejected; the CID still commits to the exact bytes. ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "aud": "did:dfos:nzkf838efr424433rn2rzkdv8h7t9ae", "att": [{ "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "write" }], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` | Field | Type | Description | | --------- | ------------------ | ------------------------------------------------------------------------ | | `version` | `1` | Schema version (literal `1`) | | `type` | `"DFOSCredential"` | Literal discriminator | | `iss` | string | Issuer DID, the authority granting permission | | `aud` | string | Audience DID, or `"*"` for public credentials | | `att` | Attenuation[] | Resource + action pairs (min 1, max 32) | | `prf` | string[] | Parent credential JWS token, at most 1 (linear delegation), default `[]` | | `exp` | number | Expiration, unix seconds (positive integer) | | `iat` | number | Issued-at, unix seconds (positive integer). Informational | `iat` records when the issuer minted the credential. It is not a verification gate: a credential is temporally authorized by `exp` against the basis time and by nothing else. ### Attenuation entry Each attenuation entry is an object with two non-empty string fields: ```json { "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "write" } ``` | Field | Type | Description | | ---------- | ------ | -------------------------------------- | | `resource` | string | Resource identifier (`type:id` format) | | `action` | string | Comma-separated action list | ### Size and cardinality limits A credential is bounded by one aggregate size cap plus a small set of cardinality caps, never a per-field string-length table. A per-field cap would only risk forking validity across implementations. **Aggregate credential size:** | Bound | Value | Applies to | | -------------------- | -------------------------- | ------------------------- | | credential JWS token | **262144 bytes** (256 KiB) | the serialized credential | Verifiers MUST reject a credential whose serialized JWS token exceeds 262144 bytes, checked before any decode. The leaf token embeds the entire nested delegation chain, each parent carried verbatim in `prf`, so this single cap bounds the whole chain. The ceiling is larger than the 64 KiB operation cap ([PROTOCOL](https://protocol.dfos.com/spec#size-and-cardinality-limits)) because a maximum-depth delegation chain legitimately exceeds 64 KiB; a credential is excluded from the operation cap and bounded by this one instead. **Cardinality caps:** | Field | Max | Note | | ----- | -------- | ---------------------------------------------------------------------------------- | | `att` | 32 items | Generous for multi-resource grants; min 1 (a zero-`att` credential grants nothing) | | `prf` | 1 item | Single-parent (linear) delegation | ### Addressing and encoding A credential is addressed and encoded like every other protocol artifact: dag-cbor canonical encoding to a CIDv1 carried in the `cid` protected header and re-derived at verification, a JSON payload in the JWS body, and the compact serialization on the wire ([PROTOCOL, CID construction](https://protocol.dfos.com/spec#cid-construction-dag-cbor--sha-256), [JWS envelope format](https://protocol.dfos.com/spec#jws-envelope-format)). The CID is what a revocation names. --- ## JWS header ```json { "alg": "EdDSA", "typ": "did:dfos:credential", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyrei..." } ``` | Field | Value | Description | | ----- | ----------------------- | --------------------------------------------------- | | `alg` | `"EdDSA"` | Ed25519 signature algorithm | | `typ` | `"did:dfos:credential"` | Protocol-specific type discriminator | | `kid` | DID URL | `did:dfos:#`, identifies the signing key | | `cid` | CID string | Content address of the payload (for revocation) | **typ.** The protected header `typ` MUST equal the exact string `did:dfos:credential`. A JWS carrying any other `typ` is not a credential and is rejected before any credential rule runs, which is what keeps a JWS signed for one purpose from being presented as another. **kid format.** The `kid` MUST be a DID URL containing `#`. The DID portion (before `#`) MUST match the `iss` field in the payload. The key fragment (after `#`) identifies which key on the issuer's identity signed. **Key resolution.** The signing key is resolved from `kid` against the issuer's identity chain, in that identity's effective state as of the [basis time](https://protocol.dfos.com/spec#time-basis). A credential carried inline in a committed operation therefore still verifies after the issuer rotates that key out, and the same key signs nothing new once it is gone. Any key role (auth, assert, controller) may sign a credential; the protocol does not restrict which role. The two withdrawals differ in reach. Rotating the signing key out ends every fresh presentation signed under it at once, because an ephemeral presentation resolves the key at the chain head, and it takes every other credential that key signed with it. Revocation is how an issuer withdraws one specific credential ahead of its expiry while the key keeps signing everything else. --- ## Delegation chains Delegation chains enable transitive authorization. A root authority issues a credential to an intermediary, who issues a narrower credential to a downstream party, embedding the parent credential as proof. ### `prf` semantics The `prf` field carries full JWS compact tokens, the complete parent credentials, not references or CIDs. Each credential is therefore self-contained: a verifier walks the entire chain with no external lookups beyond identity resolution. - `prf: []` is a root credential. The issuer is the original authority. - `prf: [""]` is a delegated credential. The single parent proves the issuer was authorized. **Delegation is linear (single-parent).** A credential's `prf` MUST contain at most one entry, and verifiers MUST reject any credential whose `prf` has more than one element. Attenuating a child against the union of several parents while rooting the walk through one of them is an authority escalation, removed here by construction. ### Verification walk Chain verification proceeds from the leaf credential upward: 1. **Verify the leaf credential:** signature, schema, `exp` against the basis, CID integrity. 2. **Reject multi-parent:** if `prf` has more than one entry, reject. 3. **Verify the parent in `prf`:** same checks, at the same basis, recursively. 4. **Audience linkage:** the child's `iss` MUST match the parent's `aud` (or the parent's `aud` MUST be `"*"`). This prevents a DID from using a credential not addressed to it. 5. **Expiry narrowing:** the child's `exp` MUST NOT exceed the parent's `exp`. 6. **Attenuation check:** the child's `att` MUST be a valid attenuation of the parent's `att` (see [Attenuation rules](#attenuation-rules)). 7. **Root check:** when a credential has `prf: []`, its `iss` MUST equal the expected root DID (for example the content chain creator). Every level of the walk uses the one basis time of the verification, the leaf's and each parent's alike. **Depth limit.** A delegation chain MUST contain at most **16 credentials**, counting the leaf and the root inclusive, so at most 15 delegation hops. A verifier walks from the leaf, counted as the first credential, toward the root; the **17th credential is rejected** ("delegation chain too deep"). This boundary is exact and normative: a verifier that accepts a 17-credential chain forks authorization validity. Conformance: a 16-credential chain verifies, a 17-credential chain is rejected. **Revocation at every level.** Revocation is checked at every level of the delegation chain, the leaf credential AND each parent, not just the leaf (MUST, see [Relay enforcement](#relay-enforcement)). --- ## Attenuation rules Every delegation hop enforces monotonic attenuation. The child credential's scope MUST be a subset of the parent's scope. Two dimensions are attenuated independently: resources and actions. ### Scope narrowing Every entry in the child's `att` array must be covered by at least one entry in the parent's `att` array. Valid narrowing: - Parent grants `chain:X` and `chain:Y`, child requests only `chain:X` (subset of resources) - Parent grants `read,write`, child requests only `read` (subset of actions) - Parent grants `chain:*`, child requests `chain:X` (wildcard to specific) Invalid widening: - Parent grants `chain:X`, child requests `chain:X` and `chain:Y` (new resource) - Parent grants `read`, child requests `read,write` (new action) - Parent grants `chain:X`, child requests `chain:*` (specific to wildcard) ### Action coverage An action is a **comma-separated list** of action tokens. To compare two action strings, each is **canonicalized to a set** of tokens by the following rules, applied identically by every verifier: 1. **Split on comma** (`,`). 2. **Trim** ASCII leading/trailing whitespace from each element. 3. **Drop empty elements.** An element that is empty after trimming contributes nothing to the set. Leading, trailing, and doubled commas are therefore insignificant: `read`, `read,`, `,read`, and `read,,read` all canonicalize to `{read}`. 4. **Collect into a set.** Order and duplication are insignificant; `write,read` and `read,write` both canonicalize to `{read, write}`. 5. **Compare tokens by exact, case-sensitive byte equality.** `read` and `Read` are distinct actions. There is **no action wildcard**: a `*` token is an ordinary, literal action token, not a match-all. The child's canonical action set MUST be a **subset** of the parent's canonical action set for the matched resource entry. Equivalently, every token in the child's set MUST appear in the parent's set. | Parent action | Child action | Canonical child set | Covered? | | ------------- | ------------- | ------------------- | -------- | | `read,write` | `read` | `{read}` | Yes | | `read,write` | `write,read` | `{read, write}` | Yes | | `read,write` | `read,,write` | `{read, write}` | Yes | | `write` | `write,` | `{write}` | Yes | | `read` | `read,write` | `{read, write}` | No | | `read` | `Read` | `{Read}` | No | **Empty action set (canonical bottom).** An action string that canonicalizes to the empty set `{}`, for example `""` or `","`, is the bottom of the action lattice: it is vacuously a subset of any parent set, so it never widens scope and passes the attenuation check, but it grants nothing, because a request always carries a concrete action token and no token is a member of `{}`. Such an entry is inert, not separately rejected. ### Expiry narrowing The child's `exp` MUST be less than or equal to every parent's `exp`. A delegated credential cannot outlive its authority. ### Expiry against the basis `exp` is signer-discretionary: the issuer chooses how long a credential is valid, and the protocol imposes no maximum. A credential is temporally authorized when its `exp` is strictly greater than the [basis time](https://protocol.dfos.com/spec#time-basis), which is where the conversion of a committed operation's `createdAt` to integer Unix seconds and the exclusive `exp` boundary are specified. A relay MAY additionally refuse credentials whose `exp` is implausibly far in the future; that is deployment policy, not the wire protocol. --- ## Resource types Two resource forms are defined under the `chain:` prefix, and two more forms are registered by the specs that consume them: [`mailbox:`](#mailboxid-signing-mailbox-deposit) and [`api:`](#apihost-credential-gated-api-access), below. The resource grammar (`type:id`) is open by construction, and an unrecognized resource type never matches a request. It may still be carried down a delegation chain under the exact-equality rule in [Attenuation between forms](#attenuation-between-forms): matching a request and surviving the attenuation walk are different questions, and only registered forms are ever matched. ### `chain:`, exact match Grants access to a specific content chain identified by its 31-character content ID. ```json { "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "write" } ``` Matching: `chain:X` matches only `chain:X`. Exact content ID comparison. ### `chain:*`, wildcard match Grants access to all content chains owned by the credential's root authority. The wildcard covers all of the issuer's content without enumerating specific chain IDs. ```json { "resource": "chain:*", "action": "read" } ``` Matching: `chain:*` matches any `chain:` request for content where the delegation chain roots at the expected creator DID. This is the broadest resource scope. Common use: granting a collaborator access to all of a creator's content. ### Attenuation between forms | Parent | Child | Valid? | Reason | | ---------------- | ---------------- | ------ | ----------------------------------------- | | `chain:*` | `chain:*` | Yes | Exact match | | `chain:*` | `chain:X` | Yes | Narrowing from wildcard to specific chain | | `chain:X` | `chain:X` | Yes | Exact match | | `chain:X` | `chain:*` | No | Widening from specific to wildcard | | `api:H` | `api:H` | Yes | Exact match | | `api:H` | `api:H/spaces/X` | Yes | Narrowing from the host to one space | | `api:H/spaces/X` | `api:H/spaces/X` | Yes | Exact match | | `api:H/spaces/X` | `api:H` | No | Widening from one space to the host | | `api:H/spaces/X` | `api:H/spaces/Y` | No | A sibling space is not narrower | The resource hierarchies from broadest to narrowest are `chain:*` > `chain:X` and `api:H` > `api:H/spaces/X`. Each delegation hop moves down a hierarchy, never up, and never across hosts. Two of these rules are general, normative for **every** resource form, not just `chain:`: - **Coverage never crosses resource types.** A `chain:` entry never covers a `mailbox:` request, nor any other pairing, in the delegation walk and in request matching alike. - **A non-`chain:` form narrows by exact byte equality of the full resource string unless its registration defines a hierarchy.** [`api:`](#apihost-credential-gated-api-access) does, one level deep and enumerated; `mailbox:` and every unregistered id do not. The wildcard is a `chain:`-only concept: a literal `*` id in any other type is an ordinary id covering only itself. ### `mailbox:`, signing mailbox deposit Grants the audience the right to **deposit** sign requests into the subject's relay mailbox. `` is the subject DID's 31-character identifier, the `did:dfos:` prefix stripped, exactly as `chain:` does not repeat its scheme. ```json { "resource": "mailbox:cnnnft9f8a2rn938d6nkz38r847v2kr", "action": "deposit" } ``` - **`deposit` is the only action.** There is no `collect`: reading one's own mailbox is proven by key possession, not delegated by credential. A credential attenuated to any other action on a `mailbox:` resource grants nothing. - **Exact match only, in the deposit gate AND the attenuation walk.** No wildcard form is defined for `mailbox`, and a relay MUST NOT honor `mailbox:*`, or any non-exact form, as covering a deposit. Delegation follows the general non-`chain:` rule in [Attenuation between forms](#attenuation-between-forms). - **The consuming rules live with the signing mailbox** ([RELAY, Deposit authorization](https://protocol.dfos.com/relay#deposit-authorization)), including the one that gives the form its teeth: a deposit credential's delegation chain MUST **root at the subject DID**, because only the subject is original authority over its own mailbox. ### `api:`, credential-gated API access Grants the audience access to the credential-gated HTTP API served at ``. `` is the API's lowercase authority: the bare hostname on the default HTTPS port, `host:port` otherwise, never a scheme or path. Host-as-id means any deployment gets the same form: a fork's credential for `api:api.example.org` gates that host exactly as `api:api.dfos.com` gates the canonical one, with no registry of deployments anywhere. Two forms are registered, one level deep: | Form | Covers | | ------------------------ | ----------------------------------------------------- | | `api:` | The whole API at that authority, every space included | | `api:/spaces/` | One space at that authority | `` is the space's protocol DID with the `did:dfos:` prefix stripped, exactly 31 characters of the [identifier alphabet](https://protocol.dfos.com/did-method#31-abnf), as `mailbox:` names a DID; never a subdomain and never a platform entity id. The form is a resource id, not an HTTP path: unversioned, and the hierarchy is enumerated, so a further child is one more row here, never a path grammar. An `api:` string that is neither form is an unregistered id: it never covers a request, and a delegation carries it only byte-identically. ```json { "resource": "api:api.dfos.com", "action": "read:profile" } ``` ```json { "resource": "api:api.dfos.com/spaces/9ctvrdn9vedda7efetrhcdakfh4cr2k", "action": "read:posts,write:comments" } ``` - **Coverage is equal-or-ancestor.** `api:` covers itself and every `api:/spaces/` at that host; a space form covers only itself. Coverage never crosses hosts (a non-default port is part of the host) and never crosses resource types. `api:*` is an ordinary id covering only itself and is never a served host. One rule decides request matching and the [attenuation walk](#attenuation-between-forms) alike, so a host grant narrows to one space and a space grant never widens. - **Registration invariant.** A child form registered here MUST NOT let an existing host grant reach anything its action tokens did not already describe: the host form means every space at the host, and a space is what every space-level token already names. - **Actions are enumerated registry tokens**, defined in [INTEGRATIONS](https://protocol.dfos.com/integrations#the-apihost-resource-and-its-actions), which registers `read:profile`, `read:email`, and `read:memberships` at the account level and `read:posts`, `write:upvotes`, `write:comments`, and `write:posts` at the space level. Growth is enumeration: a grant carrying several tokens is an ordinary comma-separated list, narrowed by dropping tokens. Per the [action lattice](#action-coverage) there is no action wildcard, so `read:*` is a literal token no route requires and an entry carrying it grants nothing. It does not widen in attenuation either: `{read:*}` narrows only from a parent that also carries `read:*`. - **The consuming rules live in [INTEGRATIONS](https://protocol.dfos.com/integrations#verification-algorithm)**, including the ones that give the form its teeth: a credential is exercised only alongside a **request proof** signed by the leaf audience's key, so a bare credential authorizes nothing on that surface; **no credential in the presented chain may carry `aud: "*"`**, refused at every level and not just the leaf, because a public parent would let a stranger self-issue a passing leaf; and the chain's **root `iss` is the subject whose data is served**, so the credential selects the subject. --- ## Public credentials ### `aud: "*"` semantics A credential with `aud` set to `"*"` is a **public credential**. It is not addressed to a specific DID: it is a standing authorization anyone can use. ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "aud": "*", "att": [{ "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "read" }], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` ### Relay ingestion Public credentials are ingested by a relay and stored as standing authorizations. When a request arrives for a resource, the relay checks its stored public credentials for matching `att` entries. The caller does not present the credential per request; the relay already holds it. ### Private credentials A credential with a specific DID as `aud` is a **private credential**. The holder presents it per request. The relay does not store it. ### Delegation chain interaction A parent credential with `aud: "*"` satisfies the audience linkage check for any child issuer, so a public credential can serve as a parent in a delegation chain: any DID can issue a narrower child credential using it as proof. ### `aud: "*"` plus write is a bearer grant Because `aud: "*"` matches any operation signer, a public credential granting a **write** action is a bearer token anyone can present: any DID can attach it as a content operation's inline `authorization` and author writes to the covered chains. A public `chain:*` write credential is world-writable across every chain rooted at the issuer. Public credentials SHOULD therefore be read-scoped. Reserve `write` and `chain:*` for private credentials with a specific `aud`, where the relay also checks that the operation signer matches the audience. Revocation is the remedy for a public write credential the issuer regrets, and the exposure runs until every relay that ingested it has the revocation. --- ## Revocation ### Revocation artifact A revocation is a standalone signed artifact that permanently invalidates a credential. Its protected header `typ` MUST equal the exact string `did:dfos:revocation`, and a JWS carrying any other `typ` is not a revocation. **JWS header:** ```json { "alg": "EdDSA", "typ": "did:dfos:revocation", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyrei..." } ``` **Payload:** ```json { "version": 1, "type": "revocation", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "credentialCID": "bafyrei...", "createdAt": "2026-03-07T00:00:00.000Z" } ``` | Field | Type | Description | | --------------- | -------------- | -------------------------------------------------------------------------------------- | | `version` | `1` | Schema version (literal `1`) | | `type` | `"revocation"` | Literal discriminator | | `did` | string | Issuer DID revoking the credential | | `credentialCID` | CID | CID of the credential being revoked | | `createdAt` | string | Timestamp in the [timestamp grammar](https://protocol.dfos.com/spec#timestamp-grammar) | ### Rules - **Issuer-only.** Only the credential's issuer DID can revoke it. The `kid` DID in the JWS header MUST match the `did` field in the payload. - **Permanent.** There is no un-revoke operation. To restore access, issue a new credential. - **CID-addressed.** The revocation artifact has its own CID, derived from the payload and embedded in the header. - **Gossiped.** Revocations propagate across relays on the proof plane like any other signed operation. ### Relay enforcement Relays maintain a revocation set keyed by `(issuerDID, credentialCID)`. During credential verification the relay checks whether the credential's CID appears in the revocation set **for that credential's issuer**. This scoping is what stops a rogue DID from revoking credentials it did not issue. A revoked credential fails verification regardless of its expiry or signature validity. Revocation MUST be checked at **every level** of a presented credential, the **leaf** AND each **parent** in its delegation chain. Checking only parents is insufficient: an unchecked revoked leaf would still authorize access. This applies to **both** authorization surfaces: the read path (standing authorization and per-request credential checks) and the write path (the inline `authorization` on a delegated content operation, verified at ingest). ### Revocation against the basis Revocation is **forward-looking against the basis**. A revocation `R` covers an artifact verified at basis time `T` if and only if: ``` R.createdAt <= T ``` The boundary is **inclusive**: a revocation signed at the same second as an operation invalidates it. `R.createdAt` comes from the revocation's own signed payload, so no relay can move the boundary by misreporting it; a caller that re-verifies the revocation JWS reads the boundary out of the verified bytes. The rule applies at every level of the presented credential, leaf and each parent, all evaluated at that one basis. For a committed operation the basis is the operation's `createdAt`, so revoking a credential does not undo operations already in the log: an operation authorized when it was signed keeps verifying, and a parent revoked after the operation does not invalidate it either. That is the append-only semantics of a content chain, and it makes every verifier reach the same verdict forever, with one exception: while the issuing identity is deleted, the credentials it issued authorize nothing anywhere in history (**Deleted issuers**, below). **Admission is separate.** Whether to admit a NEW operation is a local freshness decision, not a validity decision: a relay that holds a revocation for the authorizing credential refuses the operation whatever its `createdAt` claims. Two relays on either side of a revocation's arrival reach different admission verdicts, and nothing in the log depends on when a relay chose to admit an operation. A verifier with no revocation source, or one that answers only timelessly, MUST fall back to the timeless answer. That is the stricter direction: it can reject history the basis rule would accept, never admit what the basis rule rejects. ### Deleted issuers Identity deletion is the one credential rule that does not run against the basis. While an identity is deleted, the credentials it issued are invalidated **retroactively**: a credential from a deleted issuer authorizes nothing, on any surface, at any point in history, and verification of committed history rejects operations that relied on it. Revocation withdraws one grant and leaves the record that grant authorized standing; deletion withdraws the authority itself. Deletion is absolute, not permanent: it is the identity chain's deleted state, and the controller leaves that state with an explicit `restore` operation ([PROTOCOL, Terminal states](https://protocol.dfos.com/spec#terminal-states)). Once restored, the identity's previously issued, unrevoked credentials are honored again. Deletion suspends the issuer's authority; revocation is the permanent, per-grant kill and survives any number of delete and restore transitions. --- ## Relationship to request authentication Credentials and the request-authentication proofs of [INTEGRATIONS](https://protocol.dfos.com/integrations#api-authentication) are all DID-signed Ed25519 JWS artifacts, and they answer different questions. | Concern | Request / identity proof | DFOS credential | | ----------------- | ---------------------------------------------------- | --------------------------------------------------- | | Question answered | "Is this DID making exactly this request, now?" | "Does this DID have permission to do this?" | | Role | Authentication | Authorization | | JWS `typ` | `did:dfos:identity-proof` / `did:dfos:request-proof` | `did:dfos:credential` | | Lifetime | Seconds (the verifier-owned freshness window) | Long (hours to months) | | Binding | One exact request at one host | Specific DID or `"*"` audience | | Content-addressed | No (`cid` not in header) | Yes (`cid` in header) | | Revocable | No (expires in seconds) | Yes (via revocation artifact) | | Delegation | None | Via `prf` chains | | Basis time | Now (always an ephemeral presentation) | `createdAt` when committed inline, now at read time | A typical gated request carries both: an **identity proof** proving the caller controls a DID, and a **credential** proving that DID has access to the requested resource. --- ## Worked examples ### Simple credential Alice (`did:dfos:alice...`) grants Bob (`did:dfos:bob...`) write access to a content chain: ```json // JWS Header { "alg": "EdDSA", "typ": "did:dfos:credential", "kid": "did:dfos:alice...#key_abc", "cid": "bafyrei..." } // JWS Payload { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:alice...", "aud": "did:dfos:bob...", "att": [ { "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "write" } ], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` Alice is the root authority (`prf: []`). Bob presents this credential to a relay when writing to content chain `cv7n8vkvr64cctf3294h9k4eanhff8z`. The relay verifies Alice's signature against her key as effective at the basis, confirms the credential is neither expired nor revoked at that basis, and checks that the requested resource and action match an `att` entry. ### 2-hop delegation A space DID grants a member write access, and the member delegates to their device: ``` Space (root) -> Member -> Device (leaf) ``` **Hop 1, space issues a root credential to the member:** ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:space...", "aud": "did:dfos:member...", "att": [{ "resource": "chain:content1", "action": "write" }], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` **Hop 2, the member delegates to the device with a narrower expiry:** ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:member...", "aud": "did:dfos:device...", "att": [{ "resource": "chain:content1", "action": "write" }], "prf": [""], "exp": 1796169600, "iat": 1772841600 } ``` Verification walk for the device's credential: 1. Verify the device credential signature (signed by the member). 2. Verify the parent in `prf` (signed by the space). 3. Audience linkage: the device credential's `iss` (`member`) matches the parent's `aud` (`member`). 4. Expiry: the device credential's `exp` does not exceed the parent's `exp`, and both exceed the basis. 5. Attenuation: `chain:content1/write` is covered by the parent's `chain:content1/write`. 6. The parent has `prf: []`, so it is the root. Its `iss` (`space`) must match the expected root DID. ### Public credential A space DID issues a public read credential for a content chain. Any DID reads without presenting the credential per request: ```json { "version": 1, "type": "DFOSCredential", "iss": "did:dfos:space...", "aud": "*", "att": [{ "resource": "chain:cv7n8vkvr64cctf3294h9k4eanhff8z", "action": "read" }], "prf": [], "exp": 1798761600, "iat": 1772841600 } ``` The relay ingests this credential as a standing authorization. When any caller requests read access to `chain:cv7n8vkvr64cctf3294h9k4eanhff8z`, the relay matches the request against its stored public credentials, with no identity proof and no per-request credential. Because `aud` is `"*"`, any DID can also use this credential as a parent in a delegation chain, for example to issue a narrower credential to a specific collaborator with a shorter expiry. --- ## Source The reference TypeScript implementation is [`packages/dfos-protocol/src/credentials/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/src/credentials), published as [`@metalabel/dfos-protocol`](https://www.npmjs.com/package/@metalabel/dfos-protocol). Its Go twin is [`packages/dfos-protocol-go/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol-go). ### Related specifications - [Protocol](https://protocol.dfos.com/spec): encoding, chains, the time basis, and the signature verification profile - [Relay](https://protocol.dfos.com/relay): the HTTP surface that ingests credentials and revocations and enforces them - [Integrations](https://protocol.dfos.com/integrations): sign-in, API request authentication, and the `api:` action registry - [Guarantees](https://protocol.dfos.com/guarantees): what holds without trusting a server, what is a chosen view, and what the operator can read --- # DFOS Content Model Standard content schemas for documents committed to DFOS content chains. JSON Schema (draft 2020-12) definitions for content objects committed by CID. These schemas are the vocabulary DFOS uses internally and the starting vocabulary for applications built on the protocol. [Protocol Specification](https://protocol.dfos.com/spec) · [schemas.dfos.com](https://schemas.dfos.com) · [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/schemas) --- ## Encoding A content object is committed to a content chain by CID, derived from the canonical dag-cbor encoding of the object itself: ``` documentCID = CID(dagCborCanonicalEncode(contentObject)) ``` The protocol commits to content by hash and never inspects what is inside, with one constraint on the encoding: every number in a content object MUST be an integer in JSON's safe range (`[-(2^53 - 1), 2^53 - 1]`), with no fractions, no `NaN`, and no `±Infinity`. Encode fractional or larger-magnitude values as strings. This keeps the content CID byte-identical across implementations (see Number Encoding in the [protocol spec](https://protocol.dfos.com/spec)). Every document in this vocabulary carries a `$schema` property naming its content type. ```json { "$schema": "https://schemas.dfos.com/post/v1", "format": "short-post", "body": "Hello world." } ``` Because `$schema` is part of the content object, it is behind the `documentCID` and is cryptographically committed in the content chain. A verifier resolves the document, reads `$schema`, and validates against that schema. Documents are self-describing. `$schema` is a convention of this vocabulary rather than a protocol rule: an operation committing a document without one is a valid operation, and this vocabulary does not describe that document. --- ## Schema Versions Schemas are versioned in the URI path (`/post/v1`, `/post/v2`). - **Additive within a version.** New optional fields are added to a published version at any time, and documents already committed against that version stay valid. - **Breaking changes take a new version.** Removing a field, changing a type, or adding a required field is a new version URI, never an in-place edit. - **Implementations declare which versions they understand.** An application accepts `post/v1` and `post/v2` at once, or only `post/v1`. Two immutabilities, deliberately distinct. A committed **document** is CID-addressed and byte-immutable: a specific document never changes, and an edit is a new document with a new CID appended to the content chain. A published **schema version** evolves only additively: adding optional fields to `post/v1` never invalidates documents already committed against it. A document's own field values are fixed at the operation that set them. --- ## Documents Schema files live in [`schemas/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/schemas) in the protocol package. Each is a standalone JSON Schema (draft 2020-12) definition, served at `https://schemas.dfos.com`. ### Post (`https://schemas.dfos.com/post/v1`) The primary content type. Covers short posts and long-form posts via the `format` discriminator. Comments and replies are not `post/v1` surface: threaded content needs signed target linkage (see [Intra-chain references](#intra-chain-references-targetoperationcid)) and takes its own schema rather than additional `format` values. | Field | Type | Required | Description | | ------------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `$schema` | string | yes | `"https://schemas.dfos.com/post/v1"` | | `format` | enum | yes | `"short-post"` or `"long-post"`. Set at creation and not changed by later revisions | | `publishedAt` | string | no | Asserted original publication time, an [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) `date-time` string per the published schema's `format`. See **Two clocks** | | `title` | string | no | Post title, typically for long-post format | | `body` | string | no | Post body content, markdown (CommonMark) text | | `cover` | media | no | Cover image as a [media object](#media-object) | | `attachments` | media[] | no | Attached media as [media objects](#media-object) | | `credits` | credit[] | no | Ordered authorship credits. See [Credits](#credits) | `post/v1` is a closed shape (`additionalProperties: false`). **Two clocks.** A post and its operations carry two distinct times. The operation's `createdAt` records when the operation was signed and is protocol state. `publishedAt` records when the content was originally published, as asserted inside the signed document: a chain anchored long after the fact carries the original publication time here while its genesis operation records the later anchoring time. `publishedAt` is assertion-tier, like `credits`: the protocol verifies the signer, never the claim. A later revision MAY change it, and the operation log preserves every previously committed value, so re-dating is auditable. Operation `createdAt` is never backdated to encode publication time. **No topics.** `post/v1` defines no topic or category field. Topic labels are mutable organizational taxonomy, and a signed CID-committed document that embedded them would turn every taxonomy rename into a corpus-wide revision wave. Organization lives in [index](#index-httpsschemasdfoscomindexv1) entries and host-side projections, which are mutable and cheap to rebuild. **Body to attachment binding.** A post body MAY embed `attachment://` refs inline, for example an image reference inside markdown. Each inline ref SHOULD have a corresponding entry in `attachments` whose `uri` is that same ref. The body names media; `attachments` carries the verifiable reference. The example post documents in the [protocol spec](https://protocol.dfos.com/spec)'s test-vector section, and the matching `examples/` chain fixtures, carry an older draft credit shape and do not validate against `post/v1`. Those bytes are the input to published hashes, and a document CID from them appears verbatim in the spec's reference-artifact tables, so they are read as fixtures for the signing and verification rules they demonstrate, never as content-model examples. A drift guard in the protocol test suite pins the fixtures and the spec vectors to each other. ### Profile (`https://schemas.dfos.com/profile/v1`) The displayable identity for any agent, person, group, or space. | Field | Type | Required | Description | | ------------- | ------ | -------- | -------------------------------------------------------------- | | `$schema` | string | yes | `"https://schemas.dfos.com/profile/v1"` | | `name` | string | no | Display name | | `description` | string | no | Short bio or description | | `avatar` | media | no | Avatar image as a [media object](#media-object) | | `links` | link[] | no | External links, up to 20 `{ uri, label?, description? }` items | ```json { "$schema": "https://schemas.dfos.com/profile/v1", "name": "Alice", "avatar": { "uri": "attachment://media_abc123", "cid": "bafkreibovzpnn2y6dquvxhidhx64hg7smduemox7drjs4vprjhlbmivfli" } } ``` The credited party's profile is authoritative for its current display name. ### Index (`https://schemas.dfos.com/index/v1`) An **index chain** is a curated map of content refs: a space's catalog, an author's works, a reading list, a set of pinned items. It is an LWW-Map folded via the [canonical fold](#the-canonical-fold). Each operation commits an `index/v1` document carrying deltas, and the resolved index is the fold over the operations in the log. An index document carries an **array of deltas**. A single append sets or removes several entries at once, and the index accumulates through many small delta documents instead of re-committing a whole catalog each time. The deltas live in the document blob, which the operation-size cap does not measure: content operations commit only the `documentCID`. The protocol does not bound document blob size; any blob limit is gateway or application policy. | Field | Type | Required | Description | | --------- | ------- | -------- | -------------------------------------------- | | `$schema` | string | yes | `"https://schemas.dfos.com/index/v1"` | | `deltas` | delta[] | yes | Ordered deltas contributed by this operation | Each delta is one of two shapes: | Delta | Effect | | ---------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `{ "op": "set", "key", "value"? }` | Add or replace entry `key`. `value` is optional metadata; omit it (or use `{}`) for a pure set-membership entry | | `{ "op": "remove", "key" }` | Drop entry `key` | - **`key`** is a **content ref**, a 31-char content chain id or a CID, consistent with how refs are named elsewhere in this model. - **`value`** is an optional entry-metadata object `{ label?, order?, … }`. `label` is a display string; `order` is an integer ordering hint (integers only, per the encoding rule above). A pure set-membership index uses the degenerate `value: {}`. Unknown metadata fields are preserved. ```json { "$schema": "https://schemas.dfos.com/index/v1", "deltas": [ { "op": "set", "key": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "value": { "label": "First Release", "order": 1 } }, { "op": "remove", "key": "ccccccccccccccccccccccccccccccc" } ] } ``` **Fold semantics.** The resolved index is the [canonical fold](#the-canonical-fold) as an LWW-Map: 1. **Linearize** the operations in the log, all branches, into the fold order. 2. **Flatten** each `index/v1` document's `deltas` array in array order, producing one ordered delta stream. 3. **Fold** the stream: `set` writes `key → value`, `remove` deletes `key`. The **last delta touching a key wins** at its linearized position, so a `remove` supersedes an earlier `set`, and a later `set` re-adds a removed key. **Unknown delta shapes are skipped deterministically.** A delta whose `op` is neither `set` nor `remove`, whose `key` is not a string, or whose `set` `value` is present but not an object is ignored, not an error. Every reader skips the same deltas, so the vocabulary grows without forking existing readers. The published JSON Schema mirrors this: schema validity covers the known vocabulary constraints only (a delta needs an object shape and a string `op`; a `set` or `remove` carries a string `key`), and validators MUST NOT reject documents carrying additional delta shapes. Because the fold is branch-inclusive and last-applied-wins, an index **converges**: any ingest order of the same operation set folds to the same map, and two clients that concurrently append entries both keep their writes. If the chain's selected head is delete-terminal, the index is deleted and the fold is moot (see [Delete-terminality](#delete-terminality)). The `index/v1` fold is `foldIndexV1(ops)` in [`@metalabel/dfos-protocol/fold`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/src/fold). See [`schemas/index.v1.json`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/schemas/index.v1.json) and the worked chain in [`examples/index/`](https://github.com/metalabel/dfos/tree/main/examples/index). ### Media object The standard representation of a reference to external media bytes. Defined once here; schemas that carry media reference this shape (the `profile/v1` `avatar` field, and the `post/v1` `cover` and `attachments` fields). ```json { "uri": "attachment://media_abc123", "cid": "bafkreibovzpnn2y6dquvxhidhx64hg7smduemox7drjs4vprjhlbmivfli", "href": "https://cdn.example.com/media/abc123.jpg" } ``` | Field | Type | Required | Description | | ------ | ------ | -------- | ----------------------------------------------------------------------------------------------- | | `uri` | string | yes | Canonical reference to the media: an `attachment://` ref or any other URI. Always present | | `cid` | string | no | Content commitment: CIDv1, raw codec (`0x55`), sha2-256, base32 lowercase, over the media bytes | | `href` | string | no | Resolution hint: a plain URL where the bytes may currently be fetched. Non-normative | - **`uri`** (REQUIRED) is the stable, canonical name of the media. It is an `attachment://` ref or any other URI scheme (`ipfs://`, `https://`, …). The `uri` identifies; it does not promise integrity. - **`cid`** (OPTIONAL) is a verifiable commitment to the bytes: a CIDv1 with the **raw codec (`0x55`)** and **sha2-256**, encoded base32 lowercase (a 59-char `bafkrei…` string), computed over the media bytes **exactly as stored and served**. Media bytes are opaque binary, so a consumer verifies by hashing the fetched bytes directly; unlike document blobs, no re-canonicalization is involved. `cid` is optional because some media has no cid computed for it. When `cid` is present, a consumer SHOULD verify the bytes it receives against it. - **`href`** (OPTIONAL) is an implementation-dependent fallback: a plain URL where the bytes may currently be fetched. It is non-normative, carries no integrity promise, and MAY rot. Consumers resolve `uri` and verify with `cid`; `href` is a hint, never the reference. **The `attachment://` ref is opaque and host-scoped.** `` is an identifier meaningful to the host that committed the document, and nothing about the bytes is derivable from the ref itself. Resolution, turning the ref into fetchable bytes, is host- or gateway-dependent: a [content-plane](https://protocol.dfos.com/relay#content-plane-capability-content) host resolves it out of protocol, for example via a signed-CDN API. **The ref carries no integrity. `cid` is the only integrity commitment a media object makes**, and a media object with no `cid` gives a consumer nothing to check the bytes against. A media object is the referential case: a document is either _terminal_, where the `{ $schema, … }` blob is the content, or _referential_, where it describes how to fetch external bytes. Resolving a media object, the delivery of the actual media bytes, is outside the protocol. The content plane serves the document that _contains_ the media object as opaque bytes and never dereferences the pointer. There is no media server: media lives at the application and delivery layer, bound to the proof plane only by the signed reference, with `cid` as the optional content hash that lets a consumer verify the bytes it receives. --- ## Credits Who **signed** a thing and who **made** it are different claims. The protocol verifies signers: the `kid` DID in every operation's JWS header is cryptographic fact. Authorship is a document-layer statement, and this vocabulary expresses it in two tiers. A `credits[]` entry is the assertion tier: the identity that signed the revision asserts these credits, and nothing more is claimed. Consumers SHOULD display it as an assertion of the signer, not a verified fact. This is often exactly right, because a custodial host or space signs content on behalf of the people it credits. An entry that stops there is **unclaimed**, the ordinary case. A credited DID upgrades its credit to the proof tier by signing the credit itself: a **credit claim**, a small standalone JWS asserting `(contentId, did, role)`, carried in the entry's `claim` field. Both the entry shape and the claim payload are published as [`credit-claim/v1`](https://schemas.dfos.com/credit-claim/v1). **Claims travel inside document bytes.** A claim is not an operation and is not gossiped. Relays are not credit-claim aware: a relay sees document blobs, stores and serves them as opaque bytes, and applies exactly the access control it already applies to those bytes. A credit on gated content is therefore exactly as visible as that content, and attribution never becomes more public than the work it attributes. The verifier is always the consumer that reads the document. ### The `credits[]` entry ```json "credits": [ { "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "role": "photography", "name": "Alice", "claim": "eyJhbGciOiJFZERTQSIsInR5cCI6ImRpZDpkZm9zOmNyZWRpdC1jbGFpbSIsImtpZCI6..." }, { "did": "did:dfos:nzkf838efr424433rn2rzkdv8h7t9ae", "role": "author" } ] ``` | Field | Type | Required | Description | | ------- | ------ | -------- | -------------------------------------------------------------------------------- | | `did` | string | yes | The credited identity | | `role` | string | no | The credited role, open vocabulary, byte-exact. REQUIRED when `claim` is present | | `name` | string | no | Display name at time of publication, a convenience, never authoritative | | `claim` | string | no | A `did:dfos:credit-claim` JWS binding this entry | - Array **order is display order**, and the **first entry is the primary author**. - **`name` is a cached display string, not a source of truth.** It records what the document said when it was signed, so a document renders without resolving every credited DID. A consumer that resolves profiles SHOULD prefer the profile name. `name` is never compared during verification. - **`role` is REQUIRED whenever `claim` is present**, because `role` is a component of the bind. The published schema encodes this as `dependentRequired: { claim: ["role"] }`, and `role` has `minLength: 1`, so an empty role is malformed rather than a wildcard. - Omit `credits` entirely for unattributed content. An empty entry list and an absent list mean the same thing. The entry is a closed shape (`additionalProperties: false`). ### The credit claim envelope A credit claim is a JWS in the same envelope family as credentials and revocations, with its own `typ`. ```json { "alg": "EdDSA", "typ": "did:dfos:credit-claim", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyrei..." } ``` | Field | Value | Description | | ----- | ------------------------- | --------------------------------------------------- | | `alg` | `"EdDSA"` | Ed25519 signature algorithm | | `typ` | `"did:dfos:credit-claim"` | Protocol-specific type discriminator | | `kid` | DID URL | `did:dfos:#`, identifies the signing key | | `cid` | CID string | Content address of the payload | The `kid` MUST be a DID URL containing `#`, and its DID portion MUST equal the payload's `did`. This is the rule that makes a claim self-asserted: **only the claimant can claim its own credit.** A third party signing "Alice is the photographer" produces an invalid claim, not a weaker one; that statement already has a home in `credits[]`, where it reads as the signer's assertion. Any key role signs a claim. ```json { "version": 1, "type": "credit-claim", "contentId": "cv7n8vkvr64cctf3294h9k4eanhff8z", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "role": "photography", "createdAt": "2026-03-07T00:00:00.000Z" } ``` | Field | Type | Required | Description | | ----------------- | ---------------- | -------- | ------------------------------------------------------------------------------------------------------ | | `version` | `1` | yes | Schema version (literal `1`) | | `type` | `"credit-claim"` | yes | Literal discriminator | | `contentId` | string | yes | The 31-char content chain id this claim binds to, the binder | | `did` | string | yes | The claimant DID. MUST equal the `kid`'s DID | | `role` | string | yes | The claimed role, open vocabulary, compared byte-exact | | `createdAt` | string | yes | The [timestamp grammar](https://protocol.dfos.com/spec#timestamp-grammar) (millisecond precision, UTC) | | `asOfDocumentCID` | CID | no | Optional pinned document state | Unknown top-level fields are preserved and ignored, per the protocol's MUST-ignore-unknown rule. The CID commits to the exact bytes, so a verifier that stripped unknown keys would fail its own CID check. **`role` is an open vocabulary** compared by exact, case-sensitive byte equality. `"photography"`, `"editor"`, `"mixed by"`, and `"翻訳"` are all legal, and `"Author"` is a different role from `"author"`. No normalization of any kind is applied: not case folding, not Unicode normalization, not whitespace trimming. Applications that want a controlled vocabulary enforce it at their own layer. **`createdAt` is provenance metadata.** It records when the claimant signed. Verification never compares it against a clock, and a claim has no expiry. **Signers MUST normalize `createdAt` to whole seconds**, so the millisecond component of a signed claim is always `000`. This binds signers, not verifiers: a verifier accepts any conforming millisecond-precision timestamp. A caller-supplied timestamp is normalized rather than passed through, so re-deriving a claim from the same inputs lands on the same CID on every implementation. **`asOfDocumentCID`** is an optional stronger statement: _I credit myself on the chain, and this is the document state I was looking at._ It is not part of the bind, and consumers that ignore it are fully conformant. Omitting it is the default and is CID-neutral: an absent `asOfDocumentCID` encodes identically to a claim that never had the field. When the key is present it MUST be a non-empty string. **CID derivation** is identical to every other protocol object: ``` dagCborCanonicalEncode(payload) -> SHA-256 -> CIDv1 (dag-cbor + SHA-256) ``` The derived CID is embedded in the protected header as `cid`, and verification re-derives it from the parsed payload and compares. Mismatch is a verification failure. As everywhere else in the protocol, dag-cbor is used only for CID derivation; the JWS body carries JSON. | Bound | Value | Applies to | | ---------------------- | -------------- | -------------------------- | | credit claim JWS token | **4096 bytes** | the serialized claim token | Verifiers MUST reject a claim whose serialized JWS token exceeds 4096 bytes, checked **before any decode**. This is the claim's single aggregate byte arbiter: `role` and `contentId` carry no separate per-field length caps. The cap is tight because claims travel inside document bytes, and a document crediting twelve collaborators carries twelve tokens in its blob. This bound is validity-determining and MUST be identical across implementations. ### The bind The bind is the exact match of `(contentId, did, role)` between the entry plaintext and the signed claim payload: | Component | Entry side | Claim side | Comparison | | ----------- | ------------------------------ | ------------------- | ----------------------------------- | | `contentId` | the chain hosting the document | `payload.contentId` | exact string equality | | `did` | `entry.did` | `payload.did` | exact string equality | | `role` | `entry.role` | `payload.role` | exact, case-sensitive byte equality | All three MUST match. Nothing is normalized, folded, or coerced on either side. A claim that verifies cryptographically but names a different role than the entry it sits in is a failed bind, and the entry is **invalid**. An **absent** `entry.role` is a mismatch, not a wildcard. `payload.role` is always present and non-empty, so a claim-bearing entry without a `role` can never satisfy the third row, and verifiers MUST resolve it **invalid**. It is not unclaimed: a `claim` is present, so the entry is making a verifiable assertion and failing it. What the bind establishes, when it holds, is narrow: **the document's signer and the claimant independently assert the same credit.** It does not establish that the credit is true. What it rules out is a signer attributing work to someone who never agreed to be attributed, and a claimant attaching itself to work no one credited it for. Neither party can produce a claimed entry alone. The claim binds the chain's `contentId`, not a document CID and not the chain head CID. Binding the chain is what lets a claim survive every subsequent revision verbatim, and naming the chain in the signed bytes is what confines a claim to the one chain it was made about: a claim lifted into another chain's document fails the bind and resolves **invalid**. ### Verification states Every `credits[]` entry resolves to exactly one of four states. A consumer SHOULD surface the distinction; collapsing all four into "credited" throws away the mechanism. | State | Condition | How to read it | | ---------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | **claimed** | `claim` present, verifies, and all three bind components match | Signer and claimant both assert this credit | | **unclaimed** | No `claim` field | The signer asserts this credit; the claimant has not countersigned it | | **invalid** | `claim` present but verification or the bind fails | Something is wrong. Do not present this as either claimed or unclaimed | | **unverifiable** | `claim` present and well-formed, but the claimant's identity chain cannot be resolved | Verdict unknown. Insufficient information, not a failure | **unclaimed is legal and ordinary.** It is the default for most content: the credited party may hold no keys, or may be a person credited by a host that signs on their behalf. A consumer MUST NOT render unclaimed as suspicious. The only thing an unclaimed entry lacks is the second signature. **invalid is different in kind** and SHOULD be surfaced as such. Rendering it as unclaimed launders a failure into the ordinary case. **unverifiable is honest ignorance.** A transport failure is unverifiable, never invalid: an outage is not evidence about a signature. Consumers SHOULD NOT cache an unverifiable verdict as invalid. **The two failure verdicts MUST be machine-distinguishable.** A verifier that signals every failure as one undifferentiated error forces consumers to string-match diagnostic prose. Implementations MUST expose the verdict structurally: the reference implementations carry it as a field on a typed error (TypeScript: `CreditClaimVerifyError.reason`) and as `errors.Is`-able sentinels (Go: `ErrCreditClaimInvalid` and `ErrCreditClaimUnverifiable`). A failure whose cause cannot be attributed, such as an unexpected fault inside the verifier itself, MUST resolve to **unverifiable**. ### Verification algorithm To verify one `credits[]` entry, given the entry, the `contentId` of the chain whose document contains it, and a way to resolve identities: 1. **No `claim` field?** The entry is **unclaimed**. Stop. 2. **Size.** If the `claim` token exceeds **4096 bytes**, the entry is **invalid**. Check this before any decode. 3. **Decode** the JWS. Failure to decode resolves **invalid**. 4. **`typ`.** MUST be `did:dfos:credit-claim`. Anything else resolves **invalid**. Another envelope type from this family is not a credit claim, no matter how well it verifies. 5. **Payload schema.** `version` MUST be `1`, `type` MUST be `credit-claim`, `contentId` MUST be a 31-char content chain id, `did` MUST be non-empty and carry the `did:` prefix, `role` MUST be non-empty, `createdAt` MUST parse per the [timestamp grammar](https://protocol.dfos.com/spec#timestamp-grammar), and `asOfDocumentCID`, if the key is PRESENT, MUST be a non-empty string. Otherwise **invalid**. The `did:` check is a prefix check, not full `did:dfos` validation: a claimant is not required to be a `did:dfos` identifier. 6. **`kid` to `did`.** The `kid`'s DID portion MUST equal `payload.did`. Otherwise **invalid**. 7. **Resolve the claimant identity** named by `payload.did` and find the key named by the `kid` fragment among every key that identity chain has ever held. A claim has no [basis time](https://protocol.dfos.com/spec#time-basis) and no temporal check. Unresolvable identity resolves **unverifiable**. Resolvable identity with no such key resolves **invalid**. 8. **Signature.** Verify the JWS under that key. Failure resolves **invalid**. 9. **CID integrity.** Re-derive the payload CID and compare against the header `cid`. A missing or mismatched `cid` resolves **invalid**. 10. **The bind.** `payload.contentId` MUST equal the hosting chain's `contentId`; `payload.did` MUST equal `entry.did`; `payload.role` MUST equal `entry.role` byte-for-byte. Any mismatch resolves **invalid**. 11. Otherwise the entry is **claimed**. Step 10 is the step implementations skip. A claim that passes steps 1 to 9 is a valid signed statement by someone about something; verifying it without binding it to the entry that hosts it verifies the wrong proposition. **An empty expected `contentId` MUST be an error, never a skip**, because the empty string is what an unhydrated database column or a failed parse produces. Implementations SHOULD expose a single call that takes the whole entry plus the hosting `contentId` and returns one of the four states; the reference implementations do (`verifyCreditEntry` and `VerifyCreditEntry`), and it is the call consumers reach for. An implementation MUST resolve claimant keys from the identity chain's full key history, not from its current state. A resolver that returns only current-state keys resolves **invalid** for every claim signed before the claimant's most recent rotation, which silently un-credits real work: a claim signed in 2026 does not evaporate because the claimant rotated keys in 2028. The algorithm carries no expiry or freshness check, no revocation lookup, no relay query, and no identity-deletion gate. Verification MUST NOT consult the claimant's `isDeleted` state: a claim signed by an identity that has since been tombstoned still verifies, and its entry is still **claimed**. Attribution is history; authorization is standing. Deletion ends an identity's ability to act and does not rewrite what it did. A claim's verifiability depends on the claimant's identity chain remaining resolvable, which is not the same as the identity remaining live: a resolver that returns tombstoned identities with their keys lets old credits keep verifying, and one that returns nothing for a deleted DID yields **unverifiable**. ### Claim stability and renunciation A given `(contentId, did, role)` triple SHOULD be signed once and the resulting token reused verbatim wherever that credit appears. Re-signing produces a byte-different claim, which changes the embedding document's CID, which appends a chain operation. Store the token; do not re-mint it. The `did:dfos:credit-renounce` type is **reserved and undefined**. There is no renunciation mechanism, and implementations MUST NOT emit or accept anything under that `typ`. What a claimant does without any new primitive is stop claiming: an entry whose `claim` is dropped on the next revision is **unclaimed**, an ordinary state, and the operation log preserves the earlier revision, so nothing is falsified. A relay MAY serve a credits index as an optional profile: a `(contentId, did, role)` projection derived exclusively from the current head documents of publicly readable content, where rows are assertion-tier discovery hints that restate what a public document says, never authorization inputs and never a verdict on a claim's validity. --- ## Chain Interpretation A content chain is a signed append-only log. The protocol enforces ordering, authorship, and integrity. It does not prescribe what the chain _means_. How an application interprets a chain depends on the content types committed to it. ### Living document The chain represents a single evolving thing: a profile, a post, a policy document. Each operation is a **revision**. The resolved state is the latest `documentCID`, and history is audit trail. This is the default interpretation for the standard schemas. Edit lineage is tracked via `baseDocumentCID` on the content operation payload, so each new operation references the document CID it replaced. ### Stream The chain represents a sequence: a feed, a journal, a log. Each operation is a discrete emission, not a revision. There is no single current state; the chain is the content. The resolved content is the full ordered list of documents, and applications read a stream by walking the chain log and collecting each operation's `documentCID`. ### Event fold The chain represents a sequence of events that fold into a computed state. Each operation contributes a delta or event, and the resolved state is the result of replaying them in order. The `$schema` of the documents defines the event types and fold semantics. The chain log is the source of truth; the projected state is derived. ### Projection per schema | Schema | Projection | | ------------ | ------------------------------------------------------------------------------ | | `post/v1` | Living document. Head `documentCID` is the current post; history is edit trail | | `profile/v1` | Living document. Head `documentCID` is the current profile | | `index/v1` | Canonical fold. LWW-Map folded over the operations in the log, every branch | Stream and event-fold schemas define their own projection rules in their schema documentation. The protocol does not enforce projections; these are reading conventions that applications agree on. ### Intra-chain references (`targetOperationCID`) Content documents reference specific operations within their own chain or other chains via `targetOperationCID`: a reply naming the operation it answers, a reaction naming the operation it reacts to, an annotation naming a specific version of another chain's content. `targetOperationCID` is a content field, inside the document committed by CID, not an operation field. The protocol commits to it via `documentCID` and does not interpret or validate it. Applications resolve the reference by looking up the target operation on a relay. --- ## The Canonical Fold The [event fold](#event-fold) interpretation says a chain's resolved state is the result of replaying its operations in order. The **canonical fold** makes that order precise: a deterministic total order over the operations a reader holds, all branches rather than only the selected-head branch, so that any implementation holding the same set of operations computes the same folded state. Which operations you hold is a choice of relay. ### Linearization The fold order is the relay's head-selection comparator generalized from picking one tip to ordering the whole log. Head selection prefers, among the chain's tips, the operation with the **highest `createdAt`**, breaking ties by the **highest operation CID**, both compared byte-wise over the multibase CID string and the fixed-width ASCII [timestamp-grammar](https://protocol.dfos.com/spec#timestamp-grammar) string. It is a code-point comparison, never locale collation, so every implementation agrees. The linearization lays that same preference out in full, **ascending**, so the operation head selection would prefer sorts **last**: 1. **`createdAt` ascending** (byte-wise string comparison). 2. **Operation CID ascending** as tiebreak (byte-wise multibase string). The two orderings are exact reverses, so they never disagree: the last operation of a full-log linearization is exactly the operation head selection picks. This holds structurally, because each write's `createdAt` exceeds its predecessor's, so the operation with the greatest `createdAt` is always a tip. Sorting the head-preferred operation last is what makes the fold last-applied-wins. Head selection and the fold call the same exported comparison function, [`compareHeadPreference`](https://github.com/metalabel/dfos/blob/main/packages/dfos-protocol/src/fold/linearize.ts), so the two cannot drift. ### Branch-inclusive Folding every branch is a deliberate divergence from the head-selection reading used by living-document schemas. Head selection answers "which single document is current", and a losing fork is simply not the head. The fold answers "what is the merged state of this chain", where concurrent forks converge rather than compete, because dropping a branch would silently lose the writes on it. Both readings coexist on the same wire format: a register chain reads its head, a fold chain folds its whole log. The fold operates on already-verified operations and applies no wall clock of its own. The future-timestamp bound is enforced at ingest by the relay, and a caller folding operations it verified itself carries that obligation. ### Delete-terminality The fold assumes a live chain. If the selected head branch is delete-terminal, meaning the highest-ranked tip is a `delete`, the chain is deleted, resolution reports it as such, and the fold is moot: a consumer checks `isDeleted` from chain verification first and does not fold a deleted chain. A `delete` on a non-head branch is another superseded operation and does not delete the chain. Per-branch delete semantics are a content-chain notion; identity chains answer deletion with the explicit `restore` operation instead. ### Library The fold is a set of pure functions over already-verified operations, published at [`@metalabel/dfos-protocol/fold`](https://github.com/metalabel/dfos/tree/main/packages/dfos-protocol/src/fold), with no cryptographic or network dependencies: - `linearize(ops)` is the deterministic total order above. - `foldLwwMap(deltas)` is the generic LWW-Map fold over an ordered delta stream. - `foldIndexV1(ops)` is the [`index/v1`](#index-httpsschemasdfoscomindexv1) fold built on the two. --- ## Custom Schemas Any implementation defines custom document schemas following the same pattern: a JSON Schema with a `$schema` const field pointing to a unique URI. The protocol commits to the document via CID regardless of what is inside. The standard schemas are conventions, not constraints. Custom schema URIs use a namespace you control, for example `https://schemas.example.com/my-type/v1`, to avoid collisions with the standard library. --- # DFOS Relay A relay receives, verifies, stores, and serves the DFOS proof plane: identity chains, content chains, artifacts, countersignatures, credentials, and revocations. It optionally stores and serves the document bytes those chains commit to. This document is the HTTP wire and the ingest rules for anyone implementing or operating one. Authorship is verifiable without trusting any server. Which view of an identity you follow is a choice of relay. [Source](https://github.com/metalabel/dfos/tree/main/packages/dfos-web-relay) · [npm](https://www.npmjs.com/package/@metalabel/dfos-web-relay) · [Go relay](https://github.com/metalabel/dfos/tree/main/packages/dfos-web-relay-go) · [Protocol](https://protocol.dfos.com/spec) --- ## What a relay is A relay verifies everything it accepts and serves everything it has verified. It does not issue identity, grant permissions, or define content semantics. **A relay serves one linear view per identity.** An identity chain is linear per view ([PROTOCOL, Views](https://protocol.dfos.com/spec#views)). Two identity operations that claim the same chain position are two views of that identity. A relay admits the first successor it sees for a position and refuses later ones, so the log it serves is one linear chain. Which relay you read is which view you get. No rule in this corpus arbitrates between relays that admitted different successors at one position. **A relay carries authorship proofs; it is not an authority over them.** Every read it serves is re-derivable by the reader from operations the reader verifies itself. A relay's verdict on what it will hold is local. A signature is not. Content chains need no choice of view: they are DAGs, and deterministic head selection resolves the same operation set to the same head on every relay ([PROTOCOL, Chain validity](https://protocol.dfos.com/spec#chain-validity)). ### Two planes **The proof plane is public and replicates.** Signed chain operations, artifacts, countersignatures, credentials, and revocations are cryptographic proofs that anyone can verify with a public key. Relays push them to peers and pull them from peers; every route that reads them is unauthenticated, because the operations carry their own authentication. **The content plane holds document bytes and does not replicate.** The blobs a content chain commits to via `documentCID` are never pushed on the operation log. A blob enters a relay by upload to the relay that holds the chain. Its integrity is its `documentCID`, so served bytes are checkable against the chain whatever their source. Access is host-cooperative: the operator reads what it stores, and anything that must stay confidential against the operator is withheld or encrypted above the protocol. --- ## The well-known document `GET /.well-known/dfos-relay` is part of the contract. It is unauthenticated, ungated, and rooted, per [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615); the `dfos-relay` suffix is registered in the [extension registry's external-registrations table](https://protocol.dfos.com/spec#external-registrations). A client reads it before it calls anything else. ```json { "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "protocol": "dfos-web-relay", "version": "0.15.0", "capabilities": { "proof": true, "write": true, "content": true, "log": true, "revocations": true, "index": true, "signing": false }, "profile": "eyJhbGciOiJFZERTQSIs...", "openapi": "/openapi.json", "peers": [{ "endpoint": "https://peer.relay.example.com" }], "stats": { "pendingOps": 0, "opCount": 128, "countsByKind": { "identity": 12, "content": 30, "artifact": 5, "credential": 8, "countersign": 3, "revocation": 1 }, "oldestOpAt": "2026-03-25T00:00:00.000Z", "headCid": "bafy..." } } ``` | Field | Type | Description | | -------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `did` | string | The relay's DID, resolvable on this relay's proof plane | | `protocol` | string | Protocol identifier, always `"dfos-web-relay"` | | `version` | string | The relay's own release version (semver). The proof-plane version lives in the `/proof/v1` path prefix, not here | | `capabilities` | object | Which route families this relay serves | | `capabilities.proof` | boolean | MUST be `true`. A relay without proof plane capability is not a relay | | `capabilities.write` | boolean | Whether the relay accepts writes via `POST /proof/v1/operations` and content-plane blob upload | | `capabilities.content` | boolean | Whether the relay serves the content plane (blob upload and download) | | `capabilities.log` | boolean | Whether the global operation log is served (`GET /proof/v1/log`) | | `capabilities.revocations` | boolean | Whether the [revocation status](#revocation-status) reads are served (`GET /revocations/v1/*`). An absent flag reads as `true` | | `capabilities.index` | boolean | Whether the [index](#index-capability-index) query family is served (`GET /index/v0/*`). An absent flag reads as `false` | | `capabilities.signing` | boolean | Whether the [signing mailbox](#signing-mailbox-capability-signing) is served (`/signing/v0/*`). An absent flag reads as `false` | | `ingestion` | string | [Admission-mode](#admission) hint: `"open"`, `"proof-required"`, or `"closed"`. Absent derives from `capabilities.write`: `true` reads as `"open"`, `false` as `"closed"` | | `profile` | string | The relay's profile artifact as a compact JWS token, self-proving payload | | `openapi` | string | OPTIONAL. URL of an OpenAPI document describing this relay's HTTP surface, absolute or root-relative against the relay's base URL. Serving the document is SHOULD, never MUST; a relay that serves one advertises it here, and a relay that serves no document omits the field. The document is discovery, never authority: the routes, capability gates, and auth rules in this specification govern regardless of what an advertised document says | | `peers` | array | OPTIONAL telemetry. Configured peer relays, surfaced for mesh discovery; reference relays emit `[]` when no peers are configured | | `peers[].endpoint` | string | OPTIONAL telemetry. The peer relay's base URL | | `stats` | object | Operational counters. `stats.pendingOps` is the count of operations pending sequencing (`-1` if unavailable) | | `stats.opCount` | number | OPTIONAL telemetry. Entries the relay **holds** in its global operation log. On a relay that prunes or [culls](#retention) this is below the log's tip position and is not a proxy for it. Counts are relay-local and not comparable across relays | | `stats.countsByKind` | object | OPTIONAL telemetry. Global-log counts by primitive kind, **operations, never chains**: the `identity` bucket counts identity operations, not identities. Reference relays emit `identity`, `content`, `artifact`, `credential`, `countersign`, `revocation` | | `stats.oldestOpAt` | string \| null | OPTIONAL telemetry. `createdAt` of the oldest-position global-log entry, or `null` when the log is empty | | `stats.headCid` | string \| null | OPTIONAL telemetry. CID of the global-log tip, or `null` when the log is empty | | `stats.peerSync` | object | OPTIONAL telemetry. Per-peer sync state keyed by endpoint: `lastAttemptAt`, `lastSuccessAt`, `lastReceived`, `lastInserted`, `caughtUp`, `consecutiveFailures`, `lastReconcile*`, `pinMismatch`. Absent when no sync peer is configured | ### Capabilities are how a relay honestly declines a family A relay that does not serve a family says so in `capabilities` and answers **501 Not Implemented** on that family's routes. 501 says the capability is absent; 404 would say the resource is. The distinction is what lets a client tell "this relay does not answer that question" from "there is no such thing". - `capabilities.proof: false` is not a valid value. A compliant relay always serves the proof plane. - `capabilities.log: false` makes `GET /proof/v1/log` a 501. Per-chain logs are served regardless. - `capabilities.content: false` makes every content-plane route a 501, reads included. - `capabilities.revocations: false` makes every `/revocations/v1/*` route a 501. Revocation **enforcement** runs regardless: only the reads are gated. - `capabilities.index: false` or absent makes every `/index/v0/*` route a 501. - `capabilities.signing: false` or absent makes every `/signing/v0/*` route a 501. - `capabilities.write: false` makes `POST /proof/v1/operations` and `PUT /content/:contentId/blob/:ref` 501s. See [Write-disabled relays](#write-disabled-relays). Credential and revocation ingestion have no flag of their own: they enter through `POST /proof/v1/operations` like every other operation kind. **Capability gates fire first**, before authentication, body parsing, or any store lookup, uniformly across every gated family. Where two gates stack, blob upload is gated by `content` and then by `write`, the plane-existence gate fires first. ### Relay identity and profile Every relay has a DID that resolves on its own proof plane. It is the relay's peer identity when it gossips, and its self-proof anchor for anyone querying it. The relay MUST publish a profile artifact signed by its own DID using the head key state, under the `https://schemas.dfos.com/profile/v1` schema: ```json { "$schema": "https://schemas.dfos.com/profile/v1", "name": "edge.relay.dfos.com", "description": "Cloudflare edge relay for the DFOS network", "links": [{ "uri": "https://dfos.com", "label": "operator", "description": "DFOS Inc" }] } ``` All fields are optional except `name`, which SHOULD be present. The optional `links` array carries up to 20 `{ uri, label?, description? }` entries. The profile JWS token is inlined in the well-known response, so it is self-proving and needs no second fetch. --- ## The read contract Every route below is served when the relay holds the data and the family's capability is on. Reads are unauthenticated: the proof plane is public and its operations carry their own authentication. | Method | Path | Purpose | | ------ | ------------------------------------------- | ----------------------------------------------- | | `POST` | `/proof/v1/operations` | Submit proof-plane operations | | `GET` | `/proof/v1/operations/:cid` | One stored operation by CID | | `GET` | `/proof/v1/countersignatures/:cid` | Countersignatures on a target CID | | `GET` | `/proof/v1/identities/:did` | Projected identity state | | `GET` | `/proof/v1/identities/:did/log` | Identity chain operation log | | `GET` | `/proof/v1/content/:contentId` | Projected content-chain state | | `GET` | `/proof/v1/content/:contentId/log` | Content chain operation log | | `GET` | `/proof/v1/log` | Global ingestion-ordered operation log | | `GET` | `/revocations/v1/credential/:credentialCID` | Revocation status of one credential | | `GET` | `/revocations/v1/issuer/:did` | Every revocation this relay holds for an issuer | `POST /proof/v1/operations` is the one write route on this table; it is behind `capabilities.write` and subject to the relay's [admission policy](#admission), and it is specified in [The write contract](#the-write-contract). Everything else above is a read. The `/proof/v1` prefix encodes the plane and its version (`{plane}/{version}`), so the plane mounts or proxies as a unit by prefix. `/revocations/v1` sits at the relay root on its own prefix: revocations **enter** through `POST /proof/v1/operations`, and this family exposes a read over the index a relay already maintains for its own enforcement. Four more families sit at the root, each on its own prefix: `GET /.well-known/dfos-relay` ([above](#the-well-known-document)), `GET /1.0/identifiers/:did` (the DIF Universal Resolver binding, [DID-METHOD](https://protocol.dfos.com/did-method)), `GET /index/v0/*` ([Index](#index-capability-index)), and `/signing/v0/*` ([Signing mailbox](#signing-mailbox-capability-signing)). Content-plane blob routes live at `/content/:contentId/blob*`, distinct from the proof node's `/proof/v1/content/:contentId` paths, so a reverse proxy can fan the two planes across origins by prefix. ### Error body Every route answers failures with one body shape, `{ "error": "" }`, plus the appropriate status code. The prose is diagnostic, never contractual: callers branch on status codes and MUST NOT match message text. Two exceptions: `POST /proof/v1/operations` MAY carry an additive `details` array of per-item schema issues beside `error` on a `400` (diagnostic in the same way), and the universal resolver answers in the DIF resolution envelope its own interface requires. A store failure is answered with the same body and a 5xx status. A relay MUST NOT answer a store failure with an empty result: see [A store error is never absence](#a-store-error-is-never-absence). ### Pagination envelope Every list route paginates identically: `limit` (default 100, max 1000, values above the max clamped, never rejected) plus `after` (cursor from a previous page's `next`, passed back verbatim; omit to start) in, `next` out, the last returned row's cursor, or `null` when the page was not full (caught up). An unrecognized or undecodable cursor is a **400, never a silently empty page**. Cursor conduct splits three ways, and each route names which it has: | Conduct | Routes | An unrecognized `after`... | | -------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Relay-local positional** | the ingestion-ordered logs (global and per-chain) | **400**: cursors are meaningful only against the relay that issued them, and the client restarts its walk from the beginning (ingestion is idempotent) | | **Transparent keyset** | countersignature reads, `/revocations/v1/issuer/:did`, the index families in their lexical default | resumes strictly past `after` whether or not it names a present row, so even a foreign cursor is safe and never errors | | **Opaque token** | the index families in ordered mode, the mailbox poll | resumes strictly past the encoded key when the token decodes; an **undecodable** token is 400 | A continuously syncing client persists only server-supplied `next` values, never a fabricated one, and on `next: null` retains its last persisted cursor, cheaply re-fetching the final partial page next cycle. On a 400 it resets and re-syncs from the start. No route family invents another envelope. ### Operation read (`GET /proof/v1/operations/:cid`) ```json { "cid": "bafy…", "jwsToken": "eyJ…", "chainType": "identity", "chainId": "did:dfos:…" } ``` `chainType` is one of `identity`, `content`, `artifact`, `countersign`, `revocation`, `credential`; `chainId` is the routing identifier (DID for identity-keyed kinds, contentId for content operations, target CID for countersignatures). 404 when the CID is not held. ### Identity state (`GET /proof/v1/identities/:did`) ```json { "did": "did:dfos:…", "headCID": "bafy…", "state": { "did": "did:dfos:…", "isDeleted": false, "authKeys": [...], "assertKeys": [...], "controllerKeys": [...], "services": [...] } } ``` Projected state: the computed result of replaying the chain to its head under the core verification rules, including the identity's `services` discovery vocabulary ([PROTOCOL, Services](https://protocol.dfos.com/spec#services)). Key arrays carry the **effective** state, so a key membership whose introduction carries no valid possession envelope is absent from them ([PROTOCOL, Key possession](https://protocol.dfos.com/spec#key-possession)). Pure chain state: no derived authorization material rides this route. A deleted identity still resolves, carrying `isDeleted: true`. A sealed chain stays addressable forever, because it is the verification substrate for everything that identity ever signed. ### Content state (`GET /proof/v1/content/:contentId`) ```json { "contentId": "…", "genesisCID": "bafy…", "headCID": "bafy…", "state": { "contentId": "…", "genesisCID": "bafy…", "headCID": "bafy…", "isDeleted": false, "currentDocumentCID": "bafy…", "length": 1, "creatorDID": "did:dfos:…" } } ``` Pure chain state. Which credentials currently authorize a public read is a content-plane question, answered live on the read path, and it does not ride this route. ### Logs **Global** (`GET /proof/v1/log?after={cursor}&limit=N`): every successfully ingested operation in ingestion order, entries `{ cid, jwsToken, kind, chainId }`, `kind` one of the six primitive kinds. JWS tokens are included in every entry because proof-plane payloads are bounded, so a syncing peer replays the log without separate fetches. Cursors are relay-local and implementation-shaped (the reference relays use the entry CID; another relay MAY use an opaque composite token), and an `after` the relay does not recognize is a 400. > **`chainId` is not a per-chain partition key.** `credential` and `revocation` > entries carry `chainId` = the issuer DID, the **same** value as that DID's > `identity-op` and `artifact` entries, so folding the global log per-chain on > `chainId` alone silently co-mingles kinds under one DID. An indexer > reconstructing a specific chain MUST filter by `kind` first. **Per-chain** (`GET /proof/v1/identities/:did/log`, `GET /proof/v1/content/:contentId/log`): the chain's operations in chain order, entries `{ cid, jwsToken }`, same envelope and same 400-on-unknown-cursor rule. An identity chain is linear, so its per-chain cursor can only advance. A content chain's log enumerates **every branch** the relay accepted, not the head lineage, so a client folding it applies head selection itself. ### Countersignatures (`GET /proof/v1/countersignatures/:cid?after={cid}&limit=N`) Returns `{ "countersignatures": [{ "cid", "jwsToken" }], "next" }`, sorted by each countersignature's own CID ascending under the **transparent keyset** conduct: an `after` that is not a present key resumes at the next greater key, so cursors survive concurrent additions and cross-relay replay. Works for any CID-addressable target. 404 only when the CID is neither a known operation nor has stored countersignatures. Each countersignature's `targetCID` and `relation` live inside its signed payload: the token is the truth, and the row fields are conveniences. The row shape matches per-chain log entries, so a generic client handles both identically. ### Revocation status **Credential** (`GET /revocations/v1/credential/:credentialCID`): ```json { "credentialCID": "bafyrei…", "revoked": true, "revocation": "eyJ…" } ``` `200` revoked includes `revocation`, the full revocation JWS. `200` not revoked is `{ "credentialCID": "…", "revoked": false }` with no `revocation` key. `400` when the param is not a well-formed credential CID (`bafyrei` + 52 base32 chars). If more than one issuer has revoked the same CID, the relay answers deterministically with the lexicographically smallest `issuerDID`'s revocation. **Issuer** (`GET /revocations/v1/issuer/:did?after={cid}&limit=N`): ```json { "did": "did:dfos:…", "revocations": [{ "credentialCID": "bafyrei…", "revocation": "eyJ…" }], "next": null } ``` Every revocation this relay has ingested for the issuer, sorted by `credentialCID` ascending under the transparent-keyset conduct, so a revocation whose signer-claimed `createdAt` is backdated can never be inserted behind a client's cursor and silently skipped. `400` when the param is not a canonical 31-char `did:dfos` identifier. An issuer with none returns an empty array. **The JWS is the proof; the boolean is a convenience.** A caller re-verifies the returned revocation token itself: signature against the issuer's identity chain, CID integrity, and the issuer-only rule. And **absence is NOT proof of non-revocation**: `revoked: false` attests only that this relay has not ingested a revocation for that CID. A caller that needs stronger assurance queries several independent relays. A `501` from this family means the relay does not answer revocation-status questions, and a client MUST NOT read it as a negative answer. The two shapes carry a known privacy trade. `GET /revocations/v1/credential/:credentialCID` is OCSP-shaped ([RFC 6960](https://www.rfc-editor.org/rfc/rfc6960)) and tells the relay exactly which credential a verifier is checking. `GET /revocations/v1/issuer/:did` is CRL-shaped ([RFC 5280 §5](https://www.rfc-editor.org/rfc/rfc5280#section-5)): fetch the issuer's whole set and check locally, disclosing nothing per check at the cost of the transfer. A verifier picks its half. ### Full route surface Every route a reference relay serves, with its plane and its authentication. | Method | Path | Plane | Auth | | ------ | ------------------------------------------- | ----------- | --------------------------------------------- | | `GET` | `/.well-known/dfos-relay` | meta | none | | `POST` | `/proof/v1/operations` | proof | admission policy: anonymous or identity proof | | `GET` | `/proof/v1/operations/:cid` | proof | none | | `GET` | `/proof/v1/countersignatures/:cid` | proof | none | | `GET` | `/proof/v1/identities/:did` | proof | none | | `GET` | `/proof/v1/identities/:did/log` | proof | none | | `GET` | `/proof/v1/content/:contentId` | proof | none | | `GET` | `/proof/v1/content/:contentId/log` | proof | none | | `GET` | `/proof/v1/log` | proof | none | | `GET` | `/1.0/identifiers/:did` | meta | none | | `GET` | `/revocations/v1/credential/:credentialCID` | revocations | none | | `GET` | `/revocations/v1/issuer/:did` | revocations | none | | `GET` | `/index/v0/operations` | index | none | | `GET` | `/index/v0/identities` | index | none | | `GET` | `/index/v0/content` | index | none | | `GET` | `/index/v0/artifacts` | index | none | | `GET` | `/index/v0/countersignatures` | index | none | | `GET` | `/index/v0/credentials` | index | none | | `GET` | `/index/v0/credits` | index | none | | `POST` | `/signing/v0/requests` | signing | deposit credential (in body) | | `GET` | `/signing/v0/requests` | signing | identity proof | | `POST` | `/signing/v0/requests/:cid/response` | signing | none, validity is the auth | | `GET` | `/signing/v0/requests/:cid/response` | signing | none, CID knowledge | | `POST` | `/signing/v0/requests/:cid/decline` | signing | none, advisory | | `PUT` | `/content/:contentId/blob/:ref` | content | identity proof | | `GET` | `/content/:contentId/blob[/:ref]` | content | standing auth, or identity proof + credential | A relay SHOULD serve an [OpenAPI](https://spec.openapis.org/oas/latest.html) document describing this surface, advertised in the well-known's `openapi` field. --- ## The write contract All proof-plane operations enter through one endpoint, `POST /proof/v1/operations`, behind `capabilities.write`. Identity operations, content operations, artifacts, countersignatures, credentials, and revocations mix freely in one batch. ### Submission (`POST /proof/v1/operations`) The request body is `{ "operations": [ "", … ] }`, classified by each token's JWS `typ` header (the [extension registry](https://protocol.dfos.com/spec#extension-registry) names the values). A batch carries at most **100 tokens**; a larger array is a **400**. The reference implementations additionally guard the aggregate body at 16 MiB (**413**; a client that sees 413 chunks and retries, while 400 remains the malformed-content verdict). Gossiping peers chunk larger runs to stay within the caps. The response is `{ "results": [ { "cid", "status", "error"? } ] }` **in the same order as the input array**: `results[i]` corresponds to `operations[i]`, regardless of internal processing order, with exactly three statuses: | Status | Meaning | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `new` | First time seen, verified, stored, state changed | | `duplicate` | Already held: the exact same CID **and** JWS token, a true idempotent resubmission. Same CID with a different token is `rejected` (Ed25519 is deterministic, so a different token for the same payload means a different signing key) | | `rejected` | Verification failed (`error` says why, diagnostically) | Duplicate countersignatures (same witness DID, same target CID) MUST be deduplicated: one countersign per witness per target, a relay MUST NOT store multiple attestations from the same witness for the same target, and resubmission SHOULD return `duplicate`. ### Admission Who may ingest is a **policy axis**, not a fixed rule, and peers are not special: gossip-in, client submission, and open deposit are one door with one grammar. A submission arrives in one of two **admission modes**, **anonymous** (no proof) or **identity-proven** (an [identity proof](https://protocol.dfos.com/integrations#the-identity-proof) signed by the submitting party's own DID, `jti` required), and the relay evaluates a **relay-local admission policy** over what it now knows: a proven principal, or anonymity. Policy MAY refuse either mode. Policy content is operator-defined and outside this specification: one relay admits only DIDs its operator recognizes, another is open-anonymous under quotas, another is allowlist-only. "My peers" is one possible policy set, not a separate authentication scheme. **The evaluation ladder is normative, cheapest first:** 1. **Structural caps**: batch size, body size, token shape. Failures are **400**/**413**. 2. **Proof verification**, when a proof is presented: one signature plus a current-state key resolution. An invalid proof is **401**; an unresolvable presenter is **503**. 3. **Admission policy** over (principal | anonymous). A refusal is **403** with the ordinary error body, distinguishable from malformed (400), from an invalid proof (401), from unverifiable (503), and from capability-off (501). Refusal is request-level: nothing in the batch is examined further, and no per-item results are produced. A policy that cannot be evaluated fails **closed** (503, the server's condition, not a judgment on the caller). 4. **Full verification**: the per-item work of the sections below, only for admitted submissions. The expensive step is never spent on a submission policy refuses. The well-known's `ingestion` member advertises the mode so a client knows before attempting: `"open"` (anonymous submissions admitted, subject to policy), `"proof-required"` (anonymous refused at step 3), or `"closed"` (no external ingestion, and `POST /proof/v1/operations` answers 501 as under `capabilities.write: false`). Advertisement is a hint; the policy decision is the authority. **The `jti` replay cache is REQUIRED on every write-shaped proof.** An identity proof presented to ingestion or to blob upload MUST carry the registered [`jti` member](https://protocol.dfos.com/integrations#payload), recorded by the relay with an atomic insert-if-absent keyed on the presenter and `jti`, and expired with the freshness window. An already-seen `jti` answers **409**, INTEGRATIONS' `replayed` verdict; a missing or malformed one is 401. Policy runs before full verification, so the relay grants admission-layer effects (quota spend, reputation attribution) before it knows whether the payload is a harmless duplicate. Read-shaped proofs rely on the freshness window alone. ### Authentication The relay owns **no authentication grammar**. Every authenticated request consumes the request-bound possession-proof envelopes specified in [INTEGRATIONS, API authentication](https://protocol.dfos.com/integrations#api-authentication), and every route sits in exactly one of three tiers: | Tier | Wire | Routes | | ------------------ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | **Public** | nothing | every read above, index and revocation reads, publicly-granted blob reads, sign-response collect (CID knowledge) and decline | | **Identity proof** | `Authorization: DFOS ` | creator's own blob reads, blob upload, the mailbox poll, the authentication half of non-public blob reads, and optionally ingestion | | **Credential** | `X-Credential` presentation, or an ingested standing grant | non-creator blob reads (alongside the identity proof), mailbox deposit | Verification is INTEGRATIONS', verbatim: the relay's **own configured authority** is the `host` binding (a relay serving several hostnames selects the expected one from its own configuration, never from a request header), presenter resolution is current-state against the relay's local store, and the freshness window is the relay's to own. A route that requires a credential rejects an identity proof at the `typ` gate and the reverse. A relay MUST reject an identity proof whose presenter's current state is deleted, however valid its signature. ### Classification Each token is classified by its JWS `typ` header. Classification is unambiguous: no DID comparison is needed. | `typ` header | Classification | | ---------------------- | ------------------------ | | `did:dfos:identity-op` | Identity chain operation | | `did:dfos:content-op` | Content chain operation | | `did:dfos:artifact` | Artifact | | `did:dfos:countersign` | Countersignature | | `did:dfos:credential` | DFOS credential | | `did:dfos:revocation` | Credential revocation | ### Dependency sort Within a batch, operations are sorted by dependency priority before processing: 1. **Identity operations**, so their keys are available 2. **Artifacts**, which reference identity keys for signature verification 3. **Content operations**, which reference identity keys and may have chain dependencies 4. **Countersignatures**, which reference identity keys and an existing target Within each priority level, genesis operations (no `previousOperationCID`) are processed before extensions, so one batch bootstraps an entire identity-and-content lifecycle without multiple round trips. ### Verification Each operation is verified against the relay's stored state. - **Identity operations**: extension operations verify against the relay's current trusted state with O(1) extension verification, the trusted head state plus the new operation. Genesis operations verify the single-operation chain. - **Content operations**: extension operations verify against trusted state with `enforceAuthorization: true`. A non-creator signer MUST carry a DFOS credential with `action: "write"` attenuations in the operation's `authorization` field. - **Revocations**: the signature verifies against the revoking DID's identity state, and the payload must reference a valid credential CID. - **Artifacts**: the signature verifies against the signing DID's identity state, CID integrity is checked, the payload conforms to the declared `$schema` structure, and the CBOR-encoded payload MUST NOT exceed 16384 bytes. - **Countersignatures**: two phases. Stateless, at the protocol level: signature, CID integrity, payload schema. Stateful, at the relay: the target CID MUST exist in the relay, the witness DID MUST differ from the target's author DID, and one countersign per witness per target. Temporal checks resolve at the [basis time](https://protocol.dfos.com/spec#time-basis): for these committed artifacts the basis is each operation's own `createdAt`, so two relays reach the same verdict on the same operation whenever each one ingests it. A relay MUST NOT add an ingest-time wall-clock `exp` check. ### Chain resolution The relay routes each incoming operation to its chain. Resolution differs by kind: - **Identity genesis**: no prior chain. The relay verifies the single-operation chain and creates a new stored identity chain keyed by the new DID. - **Identity extension**: the `kid` in the JWS header is a DID URL (`did:dfos:#`). The relay extracts the DID prefix (before `#`) and looks up the existing chain. A `kid` without `#` on a non-genesis operation is rejected: it cannot be routed. - **Content genesis**: no prior chain. The relay creates a new stored content chain keyed by the content ID derived from verification. - **Content extension**: the `previousOperationCID` payload field looks up a stored operation, which carries the `chainId`; the relay then fetches the content chain by that `chainId`. If the previous operation does not exist or is not a content operation, the extension is rejected. - **Countersignatures**: the `targetCID` payload field looks up the target operation, whose author DID enforces the witness-is-not-author rule. ### Identity linearity and admission **Conflicting extension is a permanent rejection.** An incoming identity operation whose `previousOperationCID` references an operation that already has a committed child is refused with the named error `identity chains are linear: conflicting extension refused`. It is not buffered, not retried, and never admitted later: first-seen wins locally, whatever the competing operation's `createdAt` claims. The rule holds on every path an identity operation arrives by, including direct submission, gossip, sync, and read-through. A peer-log identity operation that conflicts with a locally-committed extension is refused identically: committed order in this relay's log is never re-arbitrated. The refusal is this relay's admission verdict, not a claim that the refused operation is invalid. Two operations at one chain position are two views of the identity ([PROTOCOL, Views](https://protocol.dfos.com/spec#views)). What first-seen admission buys is a log no later timestamp can reorder. An identity write this relay refuses is admitted by a relay that holds no successor at that position, so the writer either retries here or continues the chain there. **Possession status never gates admission.** The relay admits and sequences every structurally valid identity operation, meaning linearity, CIDs, timestamps, and declared-state signer validity, whatever its key proofs say. This is the relay's door of the three-door rule, normative in [PROTOCOL, Key possession](https://protocol.dfos.com/spec#key-possession). An update introducing a key without a valid proof ingests exactly like any other: the membership it claims is **void** in every projection the relay serves, and the operation itself is committed log. Rejecting it instead would make log membership depend on semantic validation, and two relays disagreeing about one operation's proofs on a linear chain is a forked log. The log answers what was written; the projections answer what counts. ### Content-chain forks and head selection Content-chain forks are accepted. If an incoming content operation's `previousOperationCID` references any operation in the chain, not just the current head, the relay verifies the extension against the chain state at that fork point and accepts it. The chain log accumulates all branches. After accepting a fork the relay recomputes the head: highest `createdAt` among tips, lexicographic highest CID as tiebreaker ([PROTOCOL, Chain validity](https://protocol.dfos.com/spec#chain-validity)). This is deterministic across relays given the same set of operations, regardless of ingestion order. To verify a fork extension the relay computes chain state at the parent CID. Implementations choose the strategy: full replay, snapshot-backed, or otherwise. **Content-chain deletes stay per-branch**: a content `delete` seals its own branch, forks rooted at a pre-delete operation remain valid, and head selection may make a non-deleted branch the head. Identity undeletion is not a fork behavior: it is the explicit `restore` operation on the linear identity chain. ### The 24-hour future bound Identity and content operations with a `createdAt` more than 24 hours in the future are rejected. Head selection favors the highest timestamp, so a far-future `createdAt` would permanently dominate content-chain head selection. The window accommodates clock drift. Identity chains select no head, and the same bound applies to their operations: one admission rule, no per-kind exception. ### Deletion and restore Deletion means the identity stops being an active participant. Historical operations remain verifiable, and no new acts flow from a deleted identity. **The one exception, stated once for every gate below:** a **`restore`** identity operation in exactly the successor-of-delete position (`previousOperationCID` = the delete's CID, signed by a controller key of the deleted head state) is the single operation a deleted identity's chain accepts. A valid restore returns the identity to active: resolution reports `deactivated: false`, and every gate below reopens for operations that follow it. - **Identity operations after deletion**: rejected, except a valid `restore` as the immediate linear successor of the `delete`. Anything else appended after a delete, including a `restore` anywhere but that position or one signed by a key not in the deleted head state, is permanently rejected. The `delete`, and any `restore`, remain permanently in the linear log. - **Content operations after deletion**: rejected. Both paths are checked, the signer's identity being deleted and the content chain's creator identity being deleted, and the chain is sealed regardless of who signs. - **Artifacts from deleted identities**: rejected. - **Credentials from deleted issuers**: rejected. Identity deletion suspends all authority, including outstanding credentials the deleted identity issued. - **Countersignatures from deleted witnesses**: rejected. Countersignatures **on** operations by deleted authors are still accepted: deletion of the target's author does not prevent other identities from attesting. **Restore resurrects, revocation terminates.** After a valid restore, credentials issued before the delete are honored again, because they were never revoked and their issuer was suspended. A credential the issuer actually revoked stays revoked forever, restore or not. Self-countersignatures, where the witness DID matches the target's author DID, are rejected at the relay. A countersignature's semantic is that a distinct witness attests. The protocol-level verifier is stateless and does not enforce this, so the relay resolves the target's author and rejects self-attestation. ### Revocations and standing credentials Revocations are ordinary proof-plane operations. When one is accepted: 1. The revoked credential's CID is recorded against its issuer, together with the revocation's own signed `createdAt`. 2. Standing authorization backed by that credential stops granting: the live per-request check denies it on the next read. A relay that keeps a candidate index MAY also evict the entry eagerly; the live check is what guarantees immediacy. 3. Future content-chain operations embedding the revoked credential as `authorization` are rejected. 4. Future content-plane requests presenting the revoked credential are rejected. **Eviction is issuer-scoped.** A revocation retracts only its own issuer's credential. A relay MUST key the eviction of a standing public credential on `(issuer DID, credential CID)`, never on the CID alone, so a DID cannot revoke credentials it did not issue ([CREDENTIALS, Revocation](https://protocol.dfos.com/credentials#revocation)). **Ingest asks freshness; re-verification asks the basis.** Points 2 to 4 are acceptance decisions, answered from what the relay currently knows: a relay MUST refuse a **new** operation authorized by a credential it already holds a revocation for, whatever that operation's `createdAt` claims, because backdating would otherwise buy a revoked delegate an indefinite write window. Re-verifying an operation the relay has **already committed**, when replaying a chain's history or ingesting a peer's log, resolves at that operation's own `createdAt` per the [time basis](https://protocol.dfos.com/spec#time-basis): a relay MUST NOT reject a committed operation because a credential in its history was revoked later. The acceptance verdict is local and never enters the replicated log; historical validity is deterministic and identical on every relay. ### Buffering and sequencing Operations may arrive before their causal dependencies. A relay MUST eventually process any structurally valid operation whose causal dependencies have been processed. An operation's causal dependencies are the minimum state required for verification: | Operation type | Dependencies | | ------------------ | ------------------------------------------------------- | | Identity genesis | None | | Identity extension | Previous identity operation (by `previousOperationCID`) | | Content genesis | Creator's identity chain (for key resolution) | | Content extension | Previous content operation + creator's identity chain | | Artifact | Signer's identity chain | | Countersignature | Signer's identity chain + target operation | A relay MUST NOT discard a structurally well-formed operation because its dependencies are temporarily unavailable. The reference strategy is store-then-verify: 1. **Store**: on receipt, store the raw JWS token in a content-addressed buffer keyed by operation CID. This is idempotent, and duplicate CIDs are ignored. 2. **Verify**: attempt full verification against current state. Three outcomes. **Sequenced**, verification succeeded and the operation is committed to chain state and the global log. **Dependency failure**, a causal dependency is missing and the operation stays in the buffer. **Permanent rejection**, structurally invalid, bad signature, deleted identity, or a conflicting extension of a committed identity operation, which will never succeed whatever state arrives. 3. **Sequence loop**: after each ingestion batch, re-attempt buffered operations in dependency order until no further progress is made. Cross-batch dependencies resolve within the providing batch's response cycle. A rejection is a dependency failure if and only if it is caused by missing state that may arrive later. The set is small and stable: the previous operation is not in the store, the identity chain is not available for key resolution, the content chain's genesis has not arrived, or fork state cannot be computed because an ancestor in the branch path is absent. All other rejections are permanent and MUST NOT be retried. In particular, an identity operation refused as a conflicting extension is permanent. The raw buffer and the sequencer are internal to the writing relay. They are not wire surface, and nothing about them is visible to a client beyond `stats.pendingOps`. **All chain-state mutations MUST be serialized.** Concurrent ingestion of operations for the same chain is a read-modify-write race: two workers read the chain log, both append, and the second write clobbers the first. Raw operation storage is idempotent and append-only and needs no serialization. ### A store error is never absence A store read that fails MUST NOT be treated as a miss, and a store write that fails MUST NOT leave a half-commit. Concretely: - A failed read during verification is a **dependency failure**, retryable, never a permanent rejection: a transient failure MUST NOT delete a buffered operation. - A failed read on an idempotency or existence check is an error answer, never "not held". - A failed write inside a batch rolls the batch back rather than reporting an outcome it did not persist. - A failed identity lookup on any authorization path refuses the request rather than falling through to a caller-supplied alternative. - An unavailable check on the read path denies rather than grants. The reason is one sentence: a relay that reads absence out of its own failures converts an operational blip into a durable, signed-looking claim about the world. ### Write-disabled relays `capabilities.write` says whether this relay accepts writes over HTTP. It says nothing about where the relay's corpus comes from. A relay MAY be an **origin**, the authoritative source of its own log, minting operations out of band from whatever system of record it runs, and refusing external writes precisely because nothing outside it is entitled to append. A relay MAY equally run as a **lite pull-only proof node** that verifies, stores, and serves the proof plane and accepts no writes, staying current by polling its peers' `/proof/v1/log` and ingesting verified operations locally. `dfos serve --no-write` runs that mode. Both advertise `write: false` with `ingestion: "closed"`, and the flag does not distinguish them: `capabilities.write` is the **admission surface**, `ingestion` is the **admission mode**, and where a relay's operations come from is a deployment fact the wire does not carry. `write: false` gates **every** write route on both planes. `POST /proof/v1/operations` and `PUT /content/:contentId/blob/:ref` both answer **501**. Because the POST route is both the client-write and the peer-gossip-ingest path, and nothing in the request distinguishes them, refusing it disables gossip-in along with client writes. Blob upload is the one route that accepts a multi-megabyte body, so leaving it open on a node whose point is a minimal attack surface would contradict the advertised capability. "Writes" quantifies over the two replicated planes: proof-plane ingestion and content-plane blob upload. The [signing mailbox](#signing-mailbox-capability-signing) is on neither plane and is governed solely by `capabilities.signing`, so a `write: false` relay MAY serve the mailbox. Its no-ingest invariant is exactly "this relay never ingests proof-plane operations". | Flags | Result | | ------------------------------ | --------------------------------------------------------------------------------------- | | `write: false` | No writes on either plane. Content-plane **reads** (blob download) still serve normally | | `content: false` | The content plane is absent entirely: all content routes 501, reads included | | `write: false, content: false` | A proof-plane-only, read-only node | ### Retention The relay maintains a global append-only operation log: every successfully ingested operation is appended in ingestion order, and identity and content chains expose per-chain views in chain order. **A relay's log is its own, and no relay's log is the corpus.** Append-only names the relay's serving discipline, entries appended in ingestion order and never reordered, not a promise that every relay holds everything forever. A relay is sovereign over its own retention. Peers MUST NOT treat any relay's log as complete, and cross-relay operation counts are not comparable. - **Retraction MUST be expressed as revocation.** Deleting bytes from one relay's log does not propagate: a peer that already synced them keeps them, and nothing on the wire says they left. The act that travels is the signed one. Retraction on an identity chain is likewise a chain operation, an `update` removing the key, never a rewrite of history. - **Relays SHOULD retain revocations indefinitely.** Revocations are the retraction record and they are tiny. A relay that prunes operations SHOULD prune around them. - **Culling by visibility is sanctioned retention.** A relay MAY hold only the operations of chains that are publicly readable and cull the rest, pruning around revocations. This is retention, not admission: content visibility is not a property of a content operation, since a chain is created and then made publicly readable later by a separate `aud: "*"` credential, so no admission policy can express "I host only public content". Two consequences are visible on the wire and are the relay's to own. **Revocations outlive what they revoke**, so a captured grant reads revoked rather than unknown. **The log has holes**, so culled operations leave permanent gaps in the relay's sequence and `opCount` is not a log position. - **A cursor past a relay's retention answers `400` or a from-scratch first page, never a silently empty page.** An empty page that reads as caught-up would convert one relay's retention into every downstream peer's silent gap. ### Artifacts and countersignatures on the wire Artifacts and countersignatures are standalone signed primitives, immutable and CID-addressable, specified in [PROTOCOL, Standalone signed statements](https://protocol.dfos.com/spec#standalone-signed-statements). The relay's additions are the stateful checks named under [Verification](#verification) and [Deletion and restore](#deletion-and-restore): target existence, witness is not author, one countersign per witness per target, and the deleted-signer gates. --- ## Profiles The four families below sit beside the read and write contracts above. The index, the signing mailbox, and the content plane are each behind their own capability flag and answer 501 on every one of their routes when the flag is off. Peering adds no route at all: it is a convention that runs over the contracts above. ## Index (capability `index`) A relay that verifies and folds chains already holds current-state projections. The index exposes read-only, cursor-paginated queries over them at `/index/v0/*`, so a light client browses and discovers without replaying the global operation log. ### Hints, not authority Index responses are discovery hints. Every row carries the identifiers needed to re-derive its claims from the proof plane. - **The index cannot lie by assertion.** Every claim in a row is verifiable against the proof plane, and a fabricated row fails the client's fold. - **The index CAN lie by omission.** A relay can be behind, partitioned, or withholding rows, and a light client cannot detect a recall gap. Absence of a row is not proof of absence. - **Index output MUST NOT be used as an authorization input.** `publicRead` is a discovery hint; content-plane access is re-derived live on every read. ### What the index may know > **Every index field and filter MUST be computable from protocol-defined fields > and verification outcomes alone. Document payloads are opaque**, except the > well-known projections below. > **The index knows who acted, when, and what things call themselves. Nothing > else.** Every index field and filter is an instance of one of three axes: **actor** (creator, signer, witness, issuer, and the declared keys through which an actor acts), **clock** (`genesisAt` / `headAt` / operation or artifact `createdAt` / relay `ingestedAt`), or **name** (the display-name registry). A query that cannot be phrased under these axes is a client-composed filter over them, or a client-side fold over verified bytes. The index serves **structural facts** the relay already computes to verify (chain kind, genesis and head CIDs, op counts, creator DIDs, operation signer sets, deletion state, countersign witnesses and targets, credential issuer and scope, revocation status, standing public-read grants, identity service entries) and **declared labels matched as opaque strings** (an artifact or document `$schema`, a `ContentAnchor` `label`), matched byte-for-byte the way an HTTP server serves a `Content-Type`. The index MUST NOT interpret document payloads, join across application semantics, rank, or reify application concepts. There is no "posts" route: an application-level notion like _post_ is a client-composed filter expression over structural parts (`docSchema=… & publicRead=true & creator=…`). ### Well-known projections The **display-name registry** is the sole exception to payload opacity. Its rule: one display-name field per enumerated `$schema`, so the index may know what a thing calls itself, never what it says. Nothing else is extracted: no descriptions, bodies, summaries, or payload timestamps. The one structured exception is the [credit projection](#credit-projection). | # | Projection | Definition | | --- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1 | `profile/v1 → name` | For an identity whose terminal `services` contain a `ContentAnchor` whose `label`, lowercased, equals `"profile"` and whose `anchor` is a content-chain identifier: if the relay holds the bytes of that chain's current head document, and the decoded document declares `$schema: "https://schemas.dfos.com/profile/v1"`, and its `name` is a non-empty string, the index surfaces that `name` on the identity row's `profile` projection. | | 2 | `post/v1 → title` | For a content chain whose current head document bytes the relay holds: if the decoded document declares `$schema: "https://schemas.dfos.com/post/v1"` and its `title` is a non-empty string, the index surfaces that `title` on the content row. | Every registry row carries the same circuit breakers, and every escape hatch resolves to the honest unknown: - A document under any `$schema` not in this table is never field-extracted. - A listed schema whose document is malformed, or whose extracted field is missing, empty, or not a string, projects `null`. No partial parses, no coercion. - Bytes the relay does not hold project `docSchema: null` and the field `null`. - **A chain that is not publicly readable** (`publicRead` is `false`) never surfaces its extracted display-name field: the projected value is `null`. Only the extracted value is withheld; the structural `anchor` / `publicRead` / `docSchema` fields still project. Extracted values are attribution-tier claims: the value is whatever the signed head document says. Clients verify by fetching the chain and re-hashing the served bytes to the committed `documentCID`. A relay MUST NOT ship extraction rules that are not listed here. ### Credit projection The second and only other payload extraction: the index may know who a public document says made it. For a content chain whose current head document bytes the relay holds, whose decoded document declares `$schema: "https://schemas.dfos.com/post/v1"`, and whose `credits` is an array, each entry with a string `did` projects one row onto the [credits route](#credits-get-indexv0creditsdidcontentidroleaftercursorlimitn) carrying `(contentId, did, role, position, hasClaim)`: `role` is the entry's string `role` or `null`, `position` is the entry's array index (`0` is the primary author), and `hasClaim` is whether the entry carries a string `claim`. Nothing else in the entry is projected. `name` is a rendering convenience whose authoritative surface is the credited DID's own profile projection, and the `claim` token stays inside the document bytes. Three normative rules ride on top of the registry's circuit breakers: - **Public-only, structurally.** Credit rows exist only while the chain's projected `publicRead` is `true` and the chain is not deleted. A non-public chain has **zero** credit rows: there is no redacted variant, and the family MUST NOT be usable to probe non-public content, because attribution must be no more public than the content it attributes ([CONTENT-MODEL, Credits](https://protocol.dfos.com/content-model#credits)). - **Head-only, full-replace.** Every recompute derives the content's complete row set from the current head document and replaces the previous set. A revision that drops a credited entry drops its row; a head whose new document bytes are absent clears the set, and rows reappear when the bytes land. - **Assertion-tier, unverified.** Rows restate what the signed head document asserts. `hasClaim` is byte-presence, not validity: the verification states are a client fold over the fetched document. ### Determinism and coverage - **Deterministic enumeration.** By default identity, content, artifact, credential, and countersignature lists are ordered lexicographically ascending by their cursor key (`did`, `contentId`, or `cid`), and all five additionally accept the route-specific time orderings below. Operations are the deliberate exception: they are a recency feed and default to `ingestedAt.desc`. Two relays holding the same operations serve identical author-time ordering and identical structural fields; relay-observed ingestion ordering is local by definition. - **Keyset pagination is the shared envelope.** Every index route paginates with `after`, `limit` (default 100, max 1000), and `next`, over the route's enumeration order. In the default lexical mode `after` is a strictly-greater cursor: a page returns the rows whose post-filter cursor key is lexicographically greater than `after`, ascending, capped at `limit`, and `next` is the last returned row's key or `null` when the page was not full. The cursor need not be a currently-present key: a value that falls between keys, or one naming a row mutated out of the active filter between pages, resumes at the next greater key rather than truncating to an empty page. Keys are immutable natural identifiers, so a row's membership or projected values may change between pages without dropping or duplicating other rows. - **Time-ordered enumeration (`order=`).** `/identities` and `/content` accept `order=genesisAt.desc` (newest chains first) or `order=headAt.desc` (most recently active first). The sort key is the composite `(timestamp descending, cursor key ascending)`, over the same author-claimed `createdAt` values surfaced as `genesisAt` / `headAt`, so ordered pages are as deterministic across relays as the lexical default. In ordered mode `after` and `next` are **opaque cursor tokens**: a client passes `next` back verbatim and MUST NOT parse or construct one. One honest weakening: `genesisAt.desc` sorts by an immutable key and is fully stable, while `headAt` is a **mutable** sort key. It is monotonically non-decreasing, so a chain updated mid-enumeration moves strictly toward the top of `headAt.desc`, into pages already served. An in-flight `headAt.desc` enumeration therefore never duplicates a row but MAY miss one that was updated while paginating; the row is not gone, it has moved to the front of a fresher enumeration. Completeness is the job of the lexical enumeration or the log replay. Only the two enumerated values exist, and an unrecognized value is a `400`. - **Operation recency ordering.** `/operations` accepts `createdAt.desc` and `ingestedAt.desc` and defaults to `ingestedAt.desc`. `/artifacts`, `/credentials`, and `/countersignatures` accept the same values while retaining lexical CID order when `order` is absent. All use the same descending-timestamp/CID-ascending composite and the same opaque ordered cursor. `createdAt` is author-claimed; `ingestedAt` is when this relay accepted the operation, so it is relay-local browse chronology rather than protocol authority. Unrecognized orders and undecodable ordered cursors are `400`. - **Boolean parameters fail closed.** A boolean filter is either absent (no filter), exactly `true`, or exactly `false`. A present empty or otherwise unparseable value is a **400**, never a silent widening to an unfiltered query. - **Coverage is bounded by held bytes.** `docSchema` and projected fields are computable only for chains whose current head document bytes the relay holds. A chain whose bytes are absent reports `docSchema: null`, an honest unknown rather than a claim of schemalessness, and a `docSchema` filter matches only chains with held, decodable head bytes, so callers MUST treat the result as a lower bound. Artifacts are the exception: a verified artifact carries its document inline in the stored JWS, so accepted artifact rows have a non-null `docSchema`. - **Timestamps are author-claimed.** `genesisAt` / `headAt` surface the `createdAt` fields signed inside the operations, not relay receipt times. - **Maintenance.** The index is fully re-derivable from the operation log plus held blobs. Projection application is driven by a sequence cursor over the operation log and runs outside the ingestion lock, so index work never widens the transaction that admits an operation. Reference implementations also expose a rebuild path for pre-existing corpora. Either way the serving contract is identical. - **`publicRead` is a last-touch snapshot and MAY lag time-based transitions.** A materialized `publicRead` reflects whether a standing public-read grant authorized anonymous read at the moment the row was last recomputed. One input to that predicate, a grant credential's `exp`, is wall-clock-relative and crosses without emitting any operation, so incremental maintenance has no event to react to: a row can continue to advertise `publicRead: true` after the grant that made it public has expired, until the next operation dirties that content or a rebuild reruns the projection. This is tolerated because the index is a discovery hint, never an authorization input: the content plane re-derives the predicate live on every read. A relay MAY additionally re-sweep public rows near expiry to tighten the hint. ### Operations (`GET /index/v0/operations?kind=&chainId=&signerKey=&order=&after={cursor}&limit=N`) Enumerates the operations this relay holds as metadata-only recency rows. ```json { "operations": [ { "cid": "bafyrei…", "kind": "content-op", "chainId": "a3n7r3nde8e4keeak92rr3aeztftvc2", "createdAt": "2026-04-02T00:00:00.000Z", "ingestedAt": "2026-04-02T00:00:01.123Z" } ], "next": null } ``` Parameters: `kind` (optional exact match, one of `identity-op`, `content-op`, `artifact`, `countersign`, `revocation`, or `credential`; `400` otherwise), `chainId` (optional exact match against the operation-log routing identifier), `signerKey` (optional exact multibase public-key match against the key the row's verified signature resolved to at ingest, an opaque byte match with no format validation and no `400`), `order` (`createdAt.desc` or `ingestedAt.desc`, default `ingestedAt.desc`), `after` (the opaque ordered cursor), and `limit` (default 100, max 1000). Filters are ANDed. The signer filter is **key-addressed, not DID-addressed**. A `kid` is an identity-local indirection, rebindable across key material by the chain that defines it; the public key a signature verified against is the immutable fact of the row, and ingestion already resolved it to verify the operation. A DID-addressed signer filter is not defined on this route: which DID stands behind a key is the identity index's question. Rows are browsing metadata, never proof: they contain no JWS, payload, title, or name. ### Artifacts (`GET /index/v0/artifacts?cid=&signer={did}&docSchema=&order=&after={cid}&limit=N`) Enumerates standalone signed artifacts, `cid` ascending by default. ```json { "artifacts": [ { "cid": "bafyrei…", "signerDID": "did:dfos:hd34z9a4tf6h62864nh4f7at6hr36r4", "createdAt": "2026-04-02T00:00:00.000Z", "ingestedAt": "2026-04-02T00:00:01.123Z", "docSchema": "https://example.com/schema/v1" } ], "next": null } ``` Parameters: `cid` (optional exact artifact-CID match, returning zero or one row, composing with the remaining filters), `signer` (optional exact DID from the artifact JWS `kid`; `400` when malformed), `docSchema` (optional exact opaque match against the inline document's `$schema`), `order` (optional `createdAt.desc` or `ingestedAt.desc`; lexical CID order when absent), `after` (a CID keyset cursor in lexical mode or an opaque ordered cursor in ordered mode), and `limit` (default 100, max 1000). Filters are ANDed. Rows carry no artifact payload; clients fetch the artifact from the proof plane and verify it before use. ### Identities (`GET /index/v0/identities?did=&key=&hasPublicProfile=&nameContains=&order=&after={did}&limit=N`) Enumerates identity chains, `did` ascending by default. ```json { "identities": [ { "did": "did:dfos:hd34z9a4tf6h62864nh4f7at6hr36r4", "headCID": "bafyrei…", "opCount": 4, "genesisAt": "2026-03-25T00:00:00.000Z", "headAt": "2026-04-02T00:00:00.000Z", "isDeleted": false, "profile": { "anchor": "a3n7r3nde8e4keeak92rr3aeztftvc2", "publicRead": true, "docSchema": "https://schemas.dfos.com/profile/v1", "name": "asha" } } ], "next": null } ``` | Field | Type | Description | | ---------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `opCount` | number | Operations stored for this chain. Identity chains are linear per view, so this is the linear operation count | | `genesisAt` / `headAt` | string | Author-claimed `createdAt` of the genesis and current head operations | | `profile` | object \| null | The well-known projection, or `null` when the identity declares no profile-labeled content-chain anchor. Non-null means the chain **names** an anchor, never that this relay holds it | | `profile.anchor` | string | The anchored contentId, the client's verification pointer. A chain fact, read from the identity's own signed services, present whenever the identity names an anchor, **including when this relay holds nothing under that contentId** | | `profile.publicRead` | boolean | Whether a standing public-read grant currently authorizes anonymous read of the anchored chain, per this relay's fold. A hint, never an access decision. `false` is the fold's answer for a chain this relay does not hold at all | | `profile.docSchema` | string \| null | `$schema` declared by the held head document. **MUST be `null` whenever the relay does not hold the anchored chain's current head bytes**, including when it holds no such chain. `null` has three causes: bytes not held, bytes that do not decode, and a decoded document declaring no string `$schema`. A non-null value is a claim that this relay holds and decoded those bytes | | `profile.name` | string \| null | Extracted per the projection table; `null` on any circuit breaker, including when the relay does not hold the anchored chain's head bytes and when `profile.publicRead` is `false` | **A named anchor and a held anchor are different facts.** `profile.anchor` comes from the identity chain; everything beside it comes from what the relay holds under that contentId. An identity that anchored a profile and later withdrew it, or whose anchored chain this relay never held or culled, keeps a non-null `profile` carrying its `anchor`, with `docSchema: null`, `name: null`, and `publicRead: false`. That row is the honest rendering of "this identity names a profile; ask elsewhere for it". Because `hasPublicProfile` folds no-anchor, private-anchor, and not-held-anchor into a single `false`, a consumer that needs to tell them apart reads the row's `profile` object, not the filter. Parameters: `did` (optional exact DID match, returning zero or one row), `key` (optional exact match against a public key the identity has ever proved, semantics below), `hasPublicProfile` (optional boolean filter on the predicate "`profile` is non-null AND `profile.publicRead` is true"), `nameContains` (optional case-insensitive substring filter over projected `profile.name`, applied before keyset pagination), `order` (optional `genesisAt.desc` or `headAt.desc`; `400` on any other value), `after` (a `did` keyset cursor in the lexical default, or an opaque token in ordered mode), `limit` (default 100, max 1000). Multiple profile-labeled anchors resolve deterministically to the one with the lexicographically smallest service `id`. **Discovery and resolution are separate promises for deleted identities.** A relay **MAY** omit deleted (`isDeleted: true`) identities from the **discovery** shapes of this route (the unfiltered listing, the keyset and ordered walks, `nameContains`, and `hasPublicProfile`). A relay **MUST** return a deleted identity, carrying `isDeleted: true`, from either **resolution** shape, `did=` and `key=`. The presence of either parameter makes the request a resolution whatever else it carries, and a relay MUST NOT let a discovery-shaped filter reintroduce the exclusion. The line is whether the caller already holds the identifier. Answering a resolution with an empty page would be a relay asserting non-existence, and `key=`'s consumers, key-loss recovery and mint-time burn checking, hold no DID to fall back to. `key=` is the reverse lookup "which identities has this key ever been proved into". The value is matched **byte-for-byte** against the multibase public-key strings whose memberships entered the chain's **effective** key state ([PROTOCOL, Key possession](https://protocol.dfos.com/spec#key-possession)) under any of the chain's accepted operations, every operation and not just the current head: an identity matches when its genesis key is the key, or when any accepted update ever introduced the key with a valid possession proof, whether or not a later update rotated it out. The filter is has-ever-proved rather than current-state, and proved rather than declared: a **void** membership never indexes, so a hostile or defective listing neither surfaces the chain in the key's recovery results nor burns the key against its true holder. One key may match many identities, and a deleted identity always still matches. There is no key-class or role column: which array carries the key, and whether it is current, is the chain's answer. The value is matched as an opaque string, so a string no operation ever proved matches nothing: no format validation, no `400`. ### Content chains (`GET /index/v0/content?contentId=&creator={did}&signer={did}&docSchema=&documentCID=&publicRead=&isDeleted=&titleContains=&order=&after={contentId}&limit=N`) Enumerates content chains, `contentId` ascending by default. All filters are ANDed exact matches. ```json { "content": [ { "contentId": "a3n7r3nde8e4keeak92rr3aeztftvc2", "genesisCID": "bafyrei…", "headCID": "bafyrei…", "creatorDID": "did:dfos:hd34z9a4tf6h62864nh4f7at6hr36r4", "isDeleted": false, "opCount": 3, "genesisAt": "2026-03-25T00:00:00.000Z", "headAt": "2026-04-02T00:00:00.000Z", "currentDocumentCID": "bafyrei…", "publicRead": true, "docSchema": "https://schemas.dfos.com/profile/v1", "title": null } ], "next": null } ``` `title` is the display-name registry projection for content rows (row 2, `post/v1 → title`): `null` for any chain whose held head document is not an enumerated schema, on any circuit breaker including a non-public chain, or when bytes are not held. Content-chain `opCount` is **branch-inclusive**: log length across all branches, not head-branch length. Parameters: `contentId` (optional exact match, returning zero or one row), `creator` (exact DID, the chain's genesis signer; `400` when malformed), `signer` (exact DID, keeping chains in which the DID signed at least one **accepted** operation, branch-inclusive, so operations on branches later deleted or abandoned still count; `400` when malformed), `docSchema` (exact opaque string match against held head bytes, a lower bound per coverage above), `documentCID` (exact match against the projected `currentDocumentCID`, the reverse lookup "who published this document"), `publicRead` (boolean), `isDeleted` (boolean exact match against terminal deletion state), `titleContains` (optional case-insensitive substring filter over projected `title`, applied before keyset pagination), `order` (optional `genesisAt.desc` or `headAt.desc`; `400` on any other value), `after` (a `contentId` keyset cursor in the lexical default, or an opaque token in ordered mode), `limit` (default 100, max 1000). A client's notion of _public posts by X_ is `creator=X&docSchema=&publicRead=true`, composed client-side. When `titleContains` is present, the query is implicitly restricted server-side to `publicRead=true` rows: a non-public chain's title is never projected, and `titleContains` MUST NOT be usable to probe non-public rows. Explicitly combining `titleContains` with `publicRead=false` is a **400**. A content chain is not tombstoned from this structural index when its subject is deleted. Concealment belongs to the credential and blob plane, so a deleted subject's chain metadata (identifiers, operation CIDs, timestamps) remains enumerable. Consumers that do not want deleted rows use `isDeleted=false`. `signer` is an actor-axis verification outcome and is deliberately raw: the creator matches their own chains, and "contributed to but did not create" is client-composed as `signer=X` minus `creator=X`. It is proof-tier, and it is never an authorship or credit claim: `credits` is assertion-tier and never enters this filter. ### Countersignatures by witness (`GET /index/v0/countersignatures?witness={did}&relation=&order=&after={cid}&limit=N`) The reverse of the proof plane's by-target route: every countersignature this relay has ingested **signed by** the given witness DID, ordered by countersignature CID ascending. ```json { "witness": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "countersignatures": [ { "cid": "bafyrei…", "targetCID": "bafyrei…", "relation": "endorses", "jwsToken": "eyJhbGciOiJFZERTQSIs…" } ], "next": null } ``` `witness` is required (`400` when missing or malformed); `relation` is an optional exact match against the countersign's opaque open-namespace tag; `order` optionally selects `createdAt.desc` or `ingestedAt.desc`; `after` is a countersignature-`cid` keyset cursor in lexical mode or an opaque token in ordered mode; `limit` defaults to 100 and maxes at 1000. The row's `relation` is `null` when omitted by the signer. Each entry carries the full JWS, so the caller re-verifies the token rather than trusting the row. ### Credentials (`GET /index/v0/credentials?issuer={did}&resource=&action=&order=&after={cid}&limit=N`) Enumerates the relay's held public credentials, `cid` ascending by default. ```json { "credentials": [ { "cid": "bafyrei…", "issuerDID": "did:dfos:hd34z9a4tf6h62864nh4f7at6hr36r4", "aud": "*", "att": [{ "resource": "chain:a3n7r3nde8e4keeak92rr3aeztftvc2", "action": "read" }], "exp": 1775088000, "jwsToken": "eyJhbGciOiJFZERTQSIs…" } ], "next": null } ``` Parameters: `issuer` (optional exact DID; `400` when malformed), `resource` (optional exact match against an `att[].resource`; when the requested resource starts with `chain:`, the `chain:*` wildcard bucket is always unioned in, because a `chain:*` grant may authorize the named chain), `action` (optional exact match against an `att[].action`, with the same candidate-match posture as `resource`), `order` (optional `createdAt.desc` or `ingestedAt.desc`; lexical CID order when absent), `after` (a credential-`cid` keyset cursor in lexical mode or an opaque ordered cursor in ordered mode), `limit` (default 100, max 1000). A credential's `createdAt` is its JWT numeric `iat` normalized to ISO 8601; `ingestedAt` is when this relay accepted it. Filters are ANDed. Expired rows remain enumerable and clients filter them locally. Only public credentials (`aud: "*"`) are ever held by the relay. Targeted bearer credentials never enter relay storage, so they are neither enumerable nor leakable here. This route returns a superset of candidates: `resource=chain:Y` returns exact `chain:Y` plus any `chain:*`. Each entry carries the full JWS. The caller folds each token against the proof plane (delegation roots at Y's creator, revocation, expiry) before treating it as authorization; the relay makes no authorization claim in the row. ### Credits (`GET /index/v0/credits?did=&contentId=&role=&after={cursor}&limit=N`) Enumerates the credit projection's rows: who _public_ head documents say made them. `did=X` answers "which publicly readable documents credit X", the query [CONTENT-MODEL](https://protocol.dfos.com/content-model#credits) refuses to serve for anything less than public content. ```json { "credits": [ { "contentId": "a3n7r3nde8e4keeak92rr3aeztftvc2", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "role": "photography", "position": 1, "hasClaim": true } ], "next": null } ``` Parameters: `did` (optional exact match against the credited entry's `did`), `contentId` (optional exact match, returning that chain's current public credit set in `position` order), `role` (optional exact opaque-string match, where entries without a role match only its absence), `after` and `limit` per the shared envelope. Filters are ANDed. Enumeration order is `(contentId ascending, position ascending)`, and because the natural key is composite, `after` and `next` are **opaque cursor tokens** in every mode. Rows for one content may be replaced wholesale between pages, so an in-flight enumeration never duplicates a row but MAY miss one that changed while paginating. Completeness for a single chain is `contentId=` on a fresh page, or the document itself. Every row is assertion-tier: it restates the current public head document, carries no claim token, and makes no validity claim. A consumer that needs the proof tier fetches the chain, re-hashes the head document, and runs the [content model](https://protocol.dfos.com/content-model#verification-algorithm) verification algorithm over the embedded entry. --- ## Signing mailbox (capability `signing`) A **sign request** is a signed envelope that says: I, this requester, ask this subject to sign exactly these bytes, as this kind of artifact, before this deadline. The mailbox is a relay-hosted, poll-based courier that carries the envelope to the subject and the resulting signature back, one mailbox per subject DID. Relays serve it when `capabilities.signing` is `true`. Signature production is not a relay concern: the signer is whoever holds the subject's key, and the signer path in service is the hosted authorize flow of [INTEGRATIONS, Profile A](https://protocol.dfos.com/integrations#profile-a-web-redirect). A relay MAY serve `signing: true` alongside `write: false`: courier state is on neither replicated plane, so the mailbox does not make a read-only relay an ingest path. ### Courier, not ledger - Mailbox state is **not proof plane**. It is never gossiped, never indexed, never referenced by any chain, and confers no meaning on its contents. A relay holds it the way a post office holds a letter. - **Retention MUST NOT exceed the envelope's `expiresAt`.** Two obligations, not one: an expired request MUST NOT be served on any route, and its bytes MUST NOT be retained at rest past expiry. A relay MUST delete expired courier state, not merely filter it from reads. A relay MAY delete opportunistically or on a sweep; the reference relays prune expired rows as they touch them and sweep once at construction. A relay MAY drop courier state earlier under its own policy: durability is not promised, and a composer that needs delivery guarantees re-deposits. - A relay stores **only** the request token, the response artifact, the decline flag, and its own bookkeeping timestamps. The deposit credential and any identity bundle are verified and **discarded**. - **No cross-subject enumeration.** No route lists mailboxes, counts them, or reveals whether a subject's mailbox exists to anyone who cannot read it. Pending requests reveal who is asking whom to sign what. - **The courier sees payloads in the clear.** A relay operator can read every pending request it carries. Deployments for which that matters run their own relay. The mailbox requires the relay to resolve the **subject's** identity chain locally: a mailbox lives where its subject's identity lives. A deposit for a subject the relay cannot resolve is refused (404), and so is a deposit for a subject whose local state is deleted: a tombstoned identity cannot be asked to sign, and its mailbox does not exist. A composer picks the subject's relay from the subject's resolved `services` entries of `type: "DfosRelay"` ([PROTOCOL, Services](https://protocol.dfos.com/spec#services)). When the subject lists more than one relay, a composer SHOULD deposit at every listed relay that advertises `signing`, and a signer SHOULD poll every relay its own state lists: one-sided selection is how a correct composer and a correct signer miss each other. Ed25519 is deterministic, so a request signed twice yields byte-identical responses and the first-write-wins gate absorbs the duplicate. ### The sign-request envelope A sign request is a JWS in the same envelope family as credentials, credit claims, and revocations, with its own `typ`. ```json { "alg": "EdDSA", "typ": "did:dfos:sign-request", "kid": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr#key_r9ev34fvc23z999veaaft83nn29zvhe", "cid": "bafyrei..." } ``` ```json { "version": 1, "type": "sign-request", "did": "did:dfos:cnnnft9f8a2rn938d6nkz38r847v2kr", "subject": "did:dfos:nzkf838efr424433rn2rzkdv8h7t9ae", "payloadTyp": "did:dfos:credit-claim", "payload": "eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiY3JlZGl0LWNsYWltIiw...", "createdAt": "2026-08-10T00:00:00.000Z", "expiresAt": "2026-08-13T00:00:00.000Z" } ``` | Field | Type | Required | Description | | ------------ | ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | | `version` | `1` | yes | Schema version (literal `1`) | | `type` | `"sign-request"` | yes | Literal discriminator | | `did` | string | yes | The requester DID, which MUST equal the `kid`'s DID | | `subject` | string | yes | The target signer DID, the only identity being asked | | `payloadTyp` | string | yes | The JWS `typ` the produced artifact MUST carry (for example `did:dfos:credit-claim`) | | `payload` | string | yes | **Unpadded base64url of the exact bytes to be signed** | | `createdAt` | string | yes | The [timestamp grammar](https://protocol.dfos.com/spec#timestamp-grammar) (millisecond precision, UTC) | | `expiresAt` | string | yes | The [timestamp grammar](https://protocol.dfos.com/spec#timestamp-grammar) (millisecond precision, UTC), hard deadline | The `kid` MUST be a DID URL containing `#`, and its DID portion MUST equal the payload's `did`: the requester signs its own ask. The requester's signing key resolves against the **current** state of its identity chain, so rotated-out keys and deleted requesters are rejected. A sign request is an ephemeral ask with a hard expiry and no revocation primitive, and rotation is how a requester whose key is compromised stops it minting asks. Any current key role may sign. **`payload` carries bytes, not JSON.** The field is the unpadded base64url encoding (RFC 4648 §5, no `=` padding) of the exact octets the composer wants signed, the octets that become the produced JWS's payload segment byte for byte. It is never a re-serialized parse. Padded input, or any encoding of the same bytes other than the canonical unpadded form, is invalid, and the decoded bytes MUST be non-empty. **One subject.** One requester asks one subject to sign one payload. Fan-out is N requests composed by the requester, not a multi-subject envelope. **`payloadTyp` names the artifact, and the artifact's own specification governs it.** The envelope does not say which of the subject's keys should sign, what the payload schema is, or what the artifact means. There is no `keyRole` field. **Timestamps are whole-second.** Signers of the envelope MUST normalize `createdAt` and `expiresAt` to whole seconds (`.000Z` millisecond component), by flooring and never rounding, so re-deriving a request from the same inputs lands on the same CID in every implementation. Unknown top-level fields on the envelope are preserved and ignored: the CID commits to the exact bytes, so a verifier that stripped unknown keys would fail its own CID check. The target payload inside `payload` is held to the opposite, stricter standard at signing time. **Expiry.** `expiresAt` MUST be strictly after `createdAt`, and `expiresAt − createdAt` MUST NOT exceed **7 days** (604800 seconds). The bound is validity-determining and identical across implementations: a verifier MUST reject an envelope whose window exceeds it, however far in the future or past the window sits. A verifier evaluates expiry against the current time, so a request is live only while `now < expiresAt`. The ceiling is a phishing control: a long-lived pending request waits in a mailbox for a moment of inattention. There is no cancellation primitive; a composer that wants a request gone stops honoring its CID, and expiry collects it. **CID derivation** is identical to every other protocol object: ``` dagCborCanonicalEncode(payload) -> SHA-256 -> CIDv1 (dag-cbor + SHA-256) ``` The derived CID is embedded in the protected header as `cid`, and verification re-derives it from the parsed payload and compares. The request CID is also the request's correlation handle everywhere: the mailbox slot key and the requester's poll handle. **Size bounds.** Both are validity-determining and MUST be identical across implementations. | Bound | Value | Applies to | | ---------------------- | -------------- | ------------------------------- | | sign-request JWS token | **8192 bytes** | the serialized envelope token | | decoded target payload | **4096 bytes** | the octets `payload` decodes to | ### Envelope verification Given the token, a way to resolve identities, and the current time: 1. **Size.** If the token exceeds **8192 bytes**, reject. Check this before any decode. 2. **Decode** the JWS and apply the [signature verification profile](https://protocol.dfos.com/spec#signature-verification-profile) header gates: `typ` MUST be exactly `did:dfos:sign-request`, `alg` exactly `EdDSA`, a `crit` member rejects, an embedded key member (`jwk`, `x5c`, …) rejects. A header whose `typ` or `kid` is missing or not a string rejects. 3. **Payload schema.** `version` MUST be `1`, `type` MUST be `"sign-request"`, `did` and `subject` MUST be non-empty and carry the `did:` prefix, `payloadTyp` MUST be a non-empty string, `payload` MUST be a non-empty string, `createdAt` and `expiresAt` MUST parse per the [timestamp grammar](https://protocol.dfos.com/spec#timestamp-grammar). The `did:` checks are prefix checks, not full `did:dfos` validation. 4. **Target bytes.** `payload` MUST decode as unpadded base64url; the decoded bytes MUST be non-empty and MUST NOT exceed **4096 bytes**. 5. **`kid` ↔ `did`.** The `kid`'s DID portion MUST equal `payload.did`. 6. **Resolve the requester** named by `did` to its **current** identity state. Unresolvable is **unverifiable**. Deleted rejects. Resolvable with no key matching the `kid` fragment in current state rejects. 7. **Signature.** Verify the JWS under that key. 8. **CID integrity.** Re-derive the payload CID and compare against the header `cid`. 9. **Temporal.** `expiresAt` MUST be strictly after `createdAt`, the window MUST NOT exceed 7 days, and `now` MUST be strictly before `expiresAt`. The two failure verdicts MUST be machine-distinguishable: **invalid** (checked and failed) versus **unverifiable** (could not check, an unresolvable requester or a resolver transport failure). The reference implementations expose the verdict structurally, never as prose to string-match. ### Signer obligations The central risk of any remote-signing protocol is display and sign divergence: the human approves what the screen says and the key signs what the bytes say. Given a verified envelope, a signer MUST, in order: 1. **Verify the subject is itself.** `subject` MUST equal the signer's own DID. A request addressed to anyone else MUST NOT be signed. `subject` sits inside the requester-signed bytes, so a request minted for one DID cannot be re-aimed at another. 2. **Refuse unknown types.** If the signer does not implement `payloadTyp`, its schema, its canonical serialization, and its signer-side rules, it MUST NOT sign. There is no generic path. 3. **Decode and parse.** Base64url-decode `payload` and parse the octets as UTF-8 JSON. Any parse failure refuses. 4. **Validate against the `payloadTyp` schema, strictly.** The parse MUST satisfy the target specification's payload schema **and MUST contain no unknown fields**. Ignore-unknown is a verifier's forward-compatibility posture; a signer is taking responsibility for bytes and cannot render a field it does not understand. The target typ's signer-side obligations apply in full. 5. **Re-canonicalize and byte-compare.** Re-serialize the validated parse using the target typ's canonical serialization and compare the result to the decoded input, byte for byte. **Any mismatch refuses.** After it passes, the input bytes are provably the unique canonical encoding of the parse: no duplicate key the parser collapsed, no non-shortest number form, no whitespace or escape trick, no key-order game. 6. **Render from the parse.** Whatever the signer displays for approval MUST be derived from the validated parse, never from requester-supplied display text (the envelope carries none) and never from a second decode. 7. **Sign the original bytes.** The produced JWS's payload segment MUST be the decoded input octets exactly, not a re-serialization, however canonical. The signer constructs its own protected header: `alg: "EdDSA"`, `typ` equal to the request's `payloadTyp`, its own `kid`, and whatever header fields the target specification requires. Steps 5 and 7 together are the contract: the byte-compare proves that rendering from the parse is rendering the bytes, and signing the original bytes proves the signature covers what was rendered. An implementation that skips the byte-compare and signs the re-serialization has the same bytes in the honest case and different bytes exactly in the adversarial one. The reference implementations ship adversarial vectors for this check (duplicate keys, non-shortest numbers, permuted key order, whitespace injection, unicode escapes of ASCII, unknown fields, sub-second timestamps), and an independent implementation copies them wholesale. **Canonical serialization** is defined per `payloadTyp`, and for every DFOS envelope family it is one rule: the payload object serialized as minimal UTF-8 JSON (no insignificant whitespace), members in the exact order of the target specification's payload table, absent optional members omitted, timestamps in `.000Z` whole-second form. A composer that builds payloads with the target's reference implementation produces canonical bytes by construction; the signer-side check exists for composers that do not. A stale request is refused and re-composed, never patched. Stateless envelopes (a credit claim, a countersign payload, a sign-in challenge) carry no reference to a chain head and cannot go stale. Chain operations embed a `previousOperationCID`, so if the chain advances while the request sits in a mailbox the produced operation names a position that is no longer the head, and what that costs depends on the chain. On an **identity** chain the sequence is linear and first-seen wins, so the produced operation is invalid at ingest and harmless everywhere. On a **content** chain it is a valid fork: the relay verifies it against the chain state at that fork point, accepts it, and it can win head selection ([Content-chain forks and head selection](#content-chain-forks-and-head-selection), [PROTOCOL, Chain validity](https://protocol.dfos.com/spec#chain-validity)). Either way the remedy is composer-side re-composition; nothing in the courier retries, rebases, or mutates bytes. ### The response **There is no response envelope.** The response to a sign request is the produced JWS: a complete, self-authenticating artifact of the requested `payloadTyp`, verifiable by anyone under its own rules with no reference to the request that solicited it. Correlation is by request CID, and the binding is byte-equality: the response's payload segment decodes to exactly the requested bytes, checkable by anyone holding both with no courier trust involved. **Declines are courier-level and advisory.** A subject that wants to say no tells the courier, unsigned. A composer MAY surface a decline as user-facing text and MUST NOT treat it as authoritative: the courier is untrusted and could fabricate or suppress declines. **Expiry is the only real terminator.** A composer MUST NOT irreversibly cancel real-world state on the strength of a decline, because a response may still arrive from another of the subject's devices until `expiresAt`. Approving twice is harmless: Ed25519 is deterministic, so the same key over the same bytes yields a byte-identical artifact. Two different enrolled keys produce two different valid artifacts, which is why the response slot is first-write-wins. ### Routes All routes live under `/signing/v0` at the relay root. Errors use the relay's uniform error body; callers branch on status codes, never on message text. #### `POST /signing/v0/requests` (deposit, credentialed) ```json { "request": "", "credential": "", "chain": ["", "..."] } ``` | Field | Required | Description | | ------------ | -------- | --------------------------------------------------------------------------- | | `request` | yes | The sign-request envelope | | `credential` | yes | The deposit credential, see [Deposit authorization](#deposit-authorization) | | `chain` | no | Identity-chain operations for identities the relay cannot resolve locally | The relay verifies the envelope (the full algorithm above, wall-clock `now`), verifies the deposit credential, and stores the request keyed by its CID. Responses: **201** with `{ "cid": "...", "expiresAt": "..." }`; **200** with the same body for an idempotent re-deposit of the identical token; **409** when the CID already holds a different request token; **400** for an invalid or expired envelope; **404** when the subject is not resolvable on this relay; **403** when the credential fails the deposit rule; **413** over the caps; **429** when the subject's pending set is at the relay's cap (relay policy, reference cap 1024). The aggregate deposit body MUST NOT exceed **524288 bytes** (512 KiB). **The deposit is self-contained.** Verifying it requires resolving the requester's identity chain and every issuer in the credential chain, identities the subject's relay may not host. The relay resolves locally first, and `chain` fills the gaps for foreign identities. Bundled operations are verified exactly as chain verification always verifies them, then used ephemerally for key resolution and **discarded**: never ingested, stored, gossiped, or folded. A deposit MUST NOT become a cross-relay resolution dependency: if the bundle plus local state does not suffice, the deposit is refused, not deferred. Local state is always the chain's true head and always wins, and a lookup that fails refuses the deposit rather than falling through to the bundle. For an identity resolved **from the bundle**, three checks degrade to "as attested by the depositor", because a prefix of an append-only chain is itself a valid chain: current-state key resolution (a bundle truncated before a rotation presents a rotated-out key as current), deletion of a credential issuer, and revocation of a mid-chain delegation. None of these reaches what the subject signs: the signer runs the full verification algorithm on every polled request against its own resolution path. What a truncated bundle buys is mailbox-slot spam, bounded by the deposit credential's own root and expiry. A deployment that will not accept that bound requires the requester and every credential issuer to be locally resolvable and rejects bundle-only deposits; the reference relays accept the bundle. #### `GET /signing/v0/requests` (poll, subject only) Authenticated with an **identity proof**: a request-bound possession proof self-signed by a current key on the subject's identity chain, host-bound to this relay. Reading your own mailbox is a "prove you are currently this DID" question, and no credential exists for it. Revoking a device's mailbox access is key rotation. The subject is the proof's `kid` DID. The response lists **pending** requests, deposited, unexpired, and unresponded, oldest first: ```json { "requests": [ { "cid": "bafyrei...", "request": "", "depositedAt": "...", "declined": false } ], "next": "..." } ``` Pagination is the shared envelope: `after`, `limit` (default 100, max 1000, values above the max clamped), and `next`. The pending set is ordered by deposit time ascending, tiebroken by request CID, and because that is a composite key `after` and `next` are **opaque cursor tokens**. Resumption is strictly past the composite key, so a decodable cursor whose request has since expired or been responded resumes at the next key. An undecodable token, or one minted for a different subject's mailbox, is a **400**: the cursor is bound to the mailbox it came from, and reusing it across subjects would silently skip the new subject's older pending requests. Reference relays cap a mailbox's pending set (relay policy, reference cap 1024), and the deposit that would exceed it is refused with **429**. Polling is the transport in full: there is no server push, no SSE, and no delivery callback. A push notification may prompt a poll, but delivery is always the poll. A signer MUST run the full verification algorithm and every signer obligation on each polled envelope. The mailbox's own checks are anti-abuse, not delegated trust: a signer treats a polled request exactly as one that arrived by QR code from a stranger. #### `POST /signing/v0/requests/{cid}/response` (respond) ```json { "response": "" } ``` **Unauthenticated, deliberately.** A valid response is unforgeable, since only a holder of the subject's key can produce it, so proving who couriers it adds nothing. The aggregate request body MUST NOT exceed **8704 bytes**, the 8192-byte token cap plus JSON-wrapper headroom, checked before any decode (**413** over it). The relay accepts the response if and only if all of the following hold; otherwise **400**, or **404** for an unknown or expired `cid`: 1. A pending request with this CID exists and is unexpired. 2. The token does not exceed **8192 bytes** (checked before any decode). 3. The JWS decodes, passes the profile header gates, and its `typ` equals the request's `payloadTyp` exactly. 4. The `kid`'s DID portion equals the request's `subject`. 5. The `kid` names a key that has appeared in the subject's identity chain. The courier gate resolves against every key the chain has held, because the artifact will be judged by its own rules downstream and the gate must not be stricter than the artifact's own verifier. 6. The payload segment decodes to **exactly the request's target bytes**. Its base64url spelling MUST be canonical (unpadded, no non-zero trailing bits): the stored artifact is served back verbatim and must round-trip identically across implementations. 7. The signature verifies under the resolved key. The slot is **first-write-wins**: at most one response per request, the first valid one stored. A re-put of the byte-identical token is idempotent (**200**); a different valid artifact, possible when the subject holds multiple enrolled keys, is refused (**409**), and the composer reads the one that won. **201** on first acceptance. Responding to a declined request is legal, and the response simply wins. #### `GET /signing/v0/requests/{cid}/response` (requester poll) **Unauthenticated.** Knowledge of the request CID is the capability: it is held by the composer that deposited the request and the subject that polled it, and it is not enumerable. The CID is a hash of the requester-signed payload and those inputs are not secret, so this route protects against enumeration, not against a guesser who already knows what was asked; and what it guards is a self-authenticating artifact, not a secret. ```json { "status": "pending" } { "status": "declined" } { "status": "responded", "response": "" } ``` **404** for an unknown or expired CID: after expiry a request and its response cease to be served at all, and a composer that wants the artifact keeps it. A composer MUST verify a fetched response itself, under the artifact's own rules. The courier's checks are not a verification the composer inherits. #### `POST /signing/v0/requests/{cid}/decline` (advisory) Unauthenticated, no body. A non-empty body under the cap is a **400**, and a body over **512 bytes** is a **413**. Sets the advisory decline flag on a pending request: **204**, idempotent on repeat; **409** if a response already exists; **404** unknown or expired. Unauthenticated is coherent because the flag carries no authority, and requiring subject auth would lend it a credibility this document denies it. No reason text is carried. ### Deposit authorization Depositing into a mailbox requires a [DFOS credential](https://protocol.dfos.com/credentials): standard verification, plus one rule that makes the mailbox the subject's own. The resource form is `mailbox:`, where `` is the subject DID's 31-character identifier with the `did:dfos:` prefix stripped, exactly as `chain:` does not repeat its scheme. The action is **`deposit`**. A relay MUST verify, at deposit time: 1. **The credential chain verifies** in full: signatures, schema, CID integrity, linear delegation, depth, monotonic attenuation, and audience linkage at every hop, with expiry evaluated against the wall clock (a deposit is a live decision) and revocation checked at every level against the relay's current knowledge. 2. **The chain roots at the subject.** The root credential's `iss` MUST equal the request's `subject` DID. Only the subject is original authority over its own mailbox. This rule is what makes a mailbox belong to its subject on an untrusted relay. 3. **The leaf reaches the requester.** The leaf credential's `aud` MUST equal the request's `did`, or be `"*"`. A public deposit credential is the subject opting into an open mailbox, carrying the same bearer-grant caution as every public credential: anyone may deposit, and the caps and rate limits are what stand between an open mailbox and a flooded one. The default posture is a named audience. 4. **Attenuation covers the deposit.** Some `att` entry on the leaf MUST cover resource `mailbox:` with action `deposit`, under the credential specification's action-canonicalization rules. The resource match is **exact only**: no wildcard form is defined for `mailbox`, and a relay MUST NOT honor `mailbox:*` as covering a deposit. There is no `collect` action. Credentials delegate authority to others, and being yourself is not a delegation: key possession is necessary and sufficient to read your own mailbox. A credential attenuated to `collect` on a mailbox grants nothing. The deposit credential falls out of consent moments that already exist: a sign-in authorization returns one alongside its proof, and a subject-rooted grant to a platform sub-delegates onward through linear chains without re-touching the subject's key. Each of those presupposes a standing relationship, and that is the bound: a stranger with no prior grant cannot deposit at all, by any route. How a stranger obtains a subject's public deposit credential is unspecified here, but the convention is that a subject MAY publish its `aud: "*"` deposit credential as a public credential on its own chain, where any composer discovers it through [`GET /index/v0/credentials?issuer=`](#credentials-get-indexv0credentialsissuerdidresourceactionorderaftercidlimitn). The deposit gate treats a discovered grant exactly as a directly-delivered one. **Caps are load-bearing on a public relay.** The 8 KiB envelope cap, the 4 KiB payload cap, the 512 KiB deposit body cap, and the deposit credential gate are the difference between a public courier and unauthenticated durable storage of arbitrary bytes. Relays SHOULD add per-requester rate limits on deposit. --- ## Peering (convention) Peering replicates proof-plane data across relays. It is a convention, not a wire contract: it adds no route a relay must serve, and it runs entirely over the read and write contracts above. | Behavior | Trigger | Mechanism | | ---------------- | ---------------- | ----------------------------------------------------- | | **Gossip-out** | New op ingested | Push to peers with `gossip: true` | | **Read-through** | Local 404 on GET | Fetch from peers with `readThrough: true` | | **Sync-in** | Scheduled poll | Pull from peers with `sync: true` via `/proof/v1/log` | Gossip fires on `new` status only: `duplicate` results are not re-gossiped, which prevents gossip storms. Read-through applies to identity chains and content chains only; operations and countersignatures are not read-through targets. When triggered, the relay fetches the full chain log from a peer and ingests it locally under full verification. Sync-in uses cursor-based pagination against the peer's global log. ```typescript interface PeerConfig { url: string; gossip?: boolean; // default: true readThrough?: boolean; // default: true sync?: boolean; // default: true } ``` There are no relay roles or types. Topology is emergent from configuration: a relay with `gossip: true, readThrough: false, sync: false` is a write-only edge node, and one with `gossip: false, readThrough: true, sync: false` is a read-only cache. **Admission is the peering rule.** Everything a peer sends enters through the same door as a client submission, with the same [admission ladder](#admission) and the same [linearity rule](#identity-linearity-and-admission): a peer-log identity operation that conflicts with a locally-committed extension is refused like any other, and committed order in this relay's log is never re-arbitrated. Peer-log ingestion inherits the peer's admission discipline, so choosing peers is a trust decision. Choosing which peers to sync from is also choosing which view of a divergent identity this relay ends up serving. **Sync discipline for pullers.** A puller persists only peer-supplied `next` values, never a cursor fabricated from an entry CID. On `next: null` it retains its last persisted cursor and cheaply re-fetches the final partial page next cycle, so a peer whose whole log fits in one page is re-read each cycle: a bounded, dedup-idempotent cost accepted in exchange for never fabricating. Every log fetch MUST surface a peer's 400 cursor rejection as a distinguishable **invalid-cursor** outcome, distinct from transport failure, because a client that collapses the 400 into a generic failure leaves the puller retrying a dead cursor forever after a peer wipes or rebuilds its log. On `invalid-cursor` the puller resets at most once per peer per sync cycle, and the reset is persisted only after a from-scratch fetch succeeds, so one spurious 400 from an intermediary cannot destroy a real high-water mark. A content-chain head switch mid-walk invalidates a read-through cursor the same way, and the correct response is one restart of that walk from the beginning. Peer log pages are decoded under a byte bound. A peer's response is untrusted input. **Peers converge by exchanging operations, and nothing bounds when.** Given the same set of operations, every relay computes the same content-chain head. Which operations a relay has is a function of its peers, its retention, and its admission verdicts, and no relay's log is the corpus. --- ## Content plane (capability `content`) The content plane is the relay's read and write face for document bytes, the preimages of the `documentCID`s content chains commit to. It does two things: it **stores bytes** addressed by a committed `documentCID`, and it **serves bytes** to readers it can verify are authorized, where authorized is a judgment re-derived live from the proof plane on every request and never trusted from a stored flag. Everything that gives a document meaning, which chain it belongs to, who committed it, who may read it, lives in the proof plane. The content plane has no chains of its own, no signatures of its own, no operation log, and no replication: blobs are never pushed on the operation log, and a blob enters a relay by upload to the relay that holds the chain. Capability flows up from the proof plane; the proof plane never reaches down. A reverse proxy can split the planes across origins: the proof node owns `GET /proof/v1/content/:contentId` and its `/log`, and the content plane owns the `/content/:contentId/blob*` sub-paths. ### Terminal and referential documents A document is either **terminal**, where the `{ $schema, … }` blob is the content, or **referential**, where the document describes how to fetch external bytes: an `ipfs://` CID, or an opaque `attachment://` resolved by an out-of-protocol signed-CDN API, optionally carrying a hash of the target bytes so a consumer can re-bind delivery to the committed reference. The relay serves the document blob either way and **never resolves a referential pointer**: dereferencing is delivery, and delivery lives outside the protocol. There is no media server here: no range requests, no partial content, no streaming, no minting of CDN URLs. Media is a content-schema convention ([CONTENT-MODEL, Media object](https://protocol.dfos.com/content-model#media-object)), never a relay primitive. ### Discovery A reader finds a content-plane host through the identity's `services` vocabulary ([PROTOCOL, Services](https://protocol.dfos.com/spec#services)). The core registers no service type for it, so a host names its own consumer type and validates it in its own spec. Discovery and authorization stay orthogonal. ### Authorization is one routine The content plane holds **no authoritative authorization state**: every decision is re-derived live from the proof plane, so nothing it stores can be served stale. A relay MAY keep a materialized index of ingested public grants as a performance optimization, an O(1) candidate lookup whose every candidate is re-verified live before it can authorize anything. The index is a cache over the operation log, fully re-derivable from it, never a source of truth. Both the public path and the delegated path reduce to the same verification, and the only difference is where the credential came from: ``` verify(credential, resource, action): resolve issuer keys from the proof plane # required for any signature check check the credential signature check the delegation chain roots at the content creator check not expired check not revoked — for EVERY link in the prf delegation chain → authorized iff all checks pass ``` - **Public path.** The reader presents no credential. The relay derives the public credentials (`aud: "*"`) covering the chain from the proof plane it already reads, and runs each through the verifier. A surviving public grant authorizes the read. The relay works from the credentials themselves, never a pre-chewed `publiclyReadable: true`. - **Delegated path.** The reader presents a DFOS credential in the `X-Credential` header. The same verifier runs over it, including the same per-link revocation check. A public grant may name `chain:` (this chain) or `chain:*` (all of the issuer's chains); either way it MUST root at the content creator to authorize. Public credentials SHOULD be read-scoped: a public `write` grant is a world-writable bearer token ([CREDENTIALS](https://protocol.dfos.com/credentials#aud--plus-write-is-a-bearer-grant)). A check the relay cannot complete denies. An unreachable revocation source or a failed store read is not an absence of revocation. ### Standing authorization Instead of presenting a read credential on every request, a credential with `aud: "*"` can be ingested by the relay as a **standing authorization**: once ingested, matching content-plane requests are authorized without an `X-Credential` header. Ingestion is ordinary, through `POST /proof/v1/operations`, and the credential is stored in the operation log like any other operation, addressable by CID and carried in the global log as `kind: "credential"`. **Authority is re-derived live, not read from a stored flag.** On every access the relay re-verifies the standing credential against current proof-plane state: signature, issuer-key resolution, expiry, revocation at every link, and a delegation chain rooted at the content creator, through the same verifier the per-request path uses. The two paths differ only in where the credential came from and an audience check that public credentials skip. A standing authorization stops granting access the moment any live check fails: the credential expires, the credential or any parent in its delegation chain is revoked, or the issuer's identity (or any delegating identity) is deleted. These are evaluated live per request, so the effect is immediate and no cache invalidation is required for correctness. ### Access Non-public content-plane requests carry an **identity proof** in the `Authorization: DFOS` header, verified against the presenter's current identity state with the relay's own configured authority as the `host` binding. There is no lifetime knob: a proof lives inside the verifier-owned freshness window, seconds rather than hours, and binds one exact request. The content creator, the DID that signed the genesis content operation, can always read their own blobs with just an identity proof. ### Blob upload (`PUT /content/:contentId/blob/:ref`) The upload path mirrors the download path: the operation CID identifies which operation's document is being uploaded. - A valid identity proof (`Authorization: DFOS`). - The operation CID MUST reference an operation in this content chain that has a `documentCID`. - The authenticated DID MUST be either the chain creator or the signer of the referenced operation, which is what enables delegated uploads. - The uploaded bytes MUST hash to the operation's `documentCID` (dag-cbor plus sha-256 verification). Blobs are stored by `(creatorDID, documentCID)`, always keyed to the chain creator regardless of who uploads. If several content chains by the same creator reference the same document, the blob is shared. ### Blob download (`GET /content/:contentId/blob[/:ref]`) - If a standing authorization exists for the content (a public credential with `aud: "*"` covering the resource), access is granted anonymously: no proof, no per-request credential. - Otherwise a valid identity proof (`Authorization: DFOS`) is required, plus: if the caller is the chain creator, no further credential; if the caller is not the creator, a DFOS credential with `action: "read"` in the `X-Credential` header, with a delegation chain rooting at the creator. The optional `:ref` parameter selects which operation's document to return: `head` (default) is the current document at chain head, and an operation CID is the document that operation committed. A public (`aud: "*"`) grant covers only the document at the chain's CURRENT head. The operative test is document-CID equality: the selected `:ref` resolves to a `documentCID`, and the grant applies only when that CID equals the chain state's current document CID. This holds however the grant arrives — a standing credential the relay holds and a public credential presented in `X-Credential` are both refused for a superseded document — so reading a superseded revision requires a credential whose audience names the requester, with a delegation chain rooting at the creator, or an identity proof as the creator. There is deliberately no relay-side document list route. Fetch `GET /content/:contentId/blob` for the document at head, `GET /content/:contentId/blob/:ref` for the document any specific operation committed, and `GET /proof/v1/content/:contentId/log` to enumerate the chain's operations, each carrying its `documentCID`. That composition is strictly more verifiable: every blob is checked against its committed `documentCID`, and the operation log is the proof-plane enumeration. ### What a 200 from the content plane means A `200` is an endorsement: a cooperating host verified against the live proof plane that a grant authorizes this read. Every input to that decision is public and re-derivable, so a caller MAY re-run the verifier itself and reach the same yes independently. | Property | Guarantee | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Can't forge** | A reader checks served bytes against the known `documentCID` by re-canonicalizing (decode-JSON, dag-cbor, sha-256), not by hashing the served bytes directly. Integrity is cryptographic even against a malicious host. | | **Can withhold / leak** | The host holds plaintext. Content-plane access control is host-cooperative: it protects an honest host from mis-serving. It is not a cryptographic vault. | What is not re-verifiable is the host's serve discipline, whether an honest host actually withholds bytes from an unauthorized reader. That is unprovable for any content host. Anything that must stay confidential against a hostile host is withheld or encrypted above the protocol. **Blobs are unsigned, and that is correct.** A blob's integrity is its CID, and the CID is already signed in the proof plane. Note the byte encoding: stored and served blob bytes are the bytes **as received**, canonically a JSON document, and NOT a re-canonicalized form. A naive `sha256(servedBytes)` will NOT equal `documentCID`; a verifier re-canonicalizes through the same decode and dag-cbor path the upload check uses. --- ## Conformance **`packages/relay-conformance` is the definition.** It is a Go integration suite that runs against any live relay over HTTP: set `RELAY_URL` and run it. A passing run is the conformance claim, and there is no central authority that grants one. ```bash RELAY_URL=http://localhost:4444 go test -v -count=1 ./... ``` Authenticated requests sign an identity proof bound to the relay's own configured authority, so the target is booted with one (`authority` / `RelayOptions.Authority` / `dfos serve --authority`) or every authenticated route answers 503. The suite binds proofs to the authority of the URL it dials; set `RELAY_AUTHORITY` when the relay is configured for a different host than the one you reach it at. Capability-gated variants self-skip unless the relay advertises the matching flag, and the gated families have posture-specific scripts that boot a relay in that posture and assert it: `scripts/run-write-disabled.sh` and `scripts/run-index-disabled.sh` assert **501** on every route of a family whose flag is off, with adjacent surfaces unaffected; `scripts/run-signing.sh` runs the signing mailbox against both reference relays; `scripts/run-proof-required.sh` asserts the admission ladder's gated posture, an anonymous submission refused **403** at request level and the same batch admitted with an identity proof. `scripts/run-conformance.sh` starts a TypeScript relay on a random port and runs the full suite. `scripts/run-parity.sh` compares the two reference relays over the same fixture: given the same accepted operations and held blobs, they serve byte-identical canonicalized-JSON rows, ordering, filters, and cursor pages. A read-only node cannot be seeded by the suite, since its POSTs answer 501, so the write-disabled variant verifies it by recomputing from the log: it pulls a served chain's log and independently re-derives the head and state, then asserts the served state matches. The served state is reproducible from the served operations alone. Declare your capability flags honestly in `/.well-known/dfos-relay`. A proof-plane-only relay is a conformant relay. State which corpora you ran: the protocol-level verifier and signer rules are proven separately by [`packages/protocol-verify`](https://github.com/metalabel/dfos/tree/main/packages/protocol-verify) and `packages/protocol-verify/vectors.json`. --- ## Not defined here Present absences, stated so a reader does not infer them: - **Peer discovery.** Static configuration only. - **Server push.** No SSE or realtime push; reads poll `GET /proof/v1/log`. - **Fork visibility.** No endpoint lists a content chain's tips or branches. - **Search.** No tokenization, ranking, or fuzzy matching. `nameContains` and `titleContains` are the index's ceiling, and anything beyond them is its own family, never an index extension. - **Branch termination.** No operation kills a content-chain fork branch. - **Rate limiting and anti-spam.** An operational concern, set per deployment. - **Blob size limits.** No protocol enforcement; deployments add limits at the middleware layer. - **Artifact `$schema` registry.** Schema names are free-form strings, with no validation beyond structural checks. - **Credentials-by-resource query.** Reverse discovery ("what can DID X read") serves no part of the read path. - **Content-plane replication.** A relay serves the bytes it stores. What a fork of an identity carries to another relay is the proof of history, not the bytes. --- ## Source The reference TypeScript relay is [`packages/dfos-web-relay/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-web-relay), published as [`@metalabel/dfos-web-relay`](https://www.npmjs.com/package/@metalabel/dfos-web-relay). Its Go twin, the full reference relay, is [`packages/dfos-web-relay-go/`](https://github.com/metalabel/dfos/tree/main/packages/dfos-web-relay-go). The conformance suite is [`packages/relay-conformance/`](https://github.com/metalabel/dfos/tree/main/packages/relay-conformance). Construction, storage, and peer-client interfaces are package documentation. ### Related specifications - [Protocol](https://protocol.dfos.com/spec): encoding, chains, views, time basis, key possession, and the signature verification profile - [Credentials](https://protocol.dfos.com/credentials): authorization credentials and revocation - [Content Model](https://protocol.dfos.com/content-model): document schemas and the credit vocabulary - [Integrations](https://protocol.dfos.com/integrations): sign-in, API authentication, and origin binding - [DID Method: `did:dfos`](https://protocol.dfos.com/did-method): the W3C DID method registration - [Guarantees](https://protocol.dfos.com/guarantees): what holds without trusting a server, what is a chosen view, and what the operator can read --- # DFOS Integrations This document is for a site or an application integrating with DFOS. It specifies four surfaces: **sign in**, where an application verifies who a user is; **API authentication**, where a party spends a grant against an HTTP API; **origin binding**, where an identity and a web domain name each other; and **key ceremonies**, where a key that lives on a human's own device joins an identity chain. Everything here builds on [PROTOCOL](https://protocol.dfos.com/spec) and adds no chain rules to it. Authorship is verifiable without trusting any server. Which view of an identity you follow is a choice of relay. **Time basis.** Every temporal check resolves against a basis time, defined once in [PROTOCOL, Time basis](https://protocol.dfos.com/spec#time-basis). Every artifact in this document is an **ephemeral presentation**, so its basis is now: the signing key MUST be effective in the identity's state at the chain head as the resolving relay serves it, an `exp` MUST be strictly greater than now, and no revocation effective now may cover it. A key rotated out signs nothing new. **JWS profile.** Every JWS here is verified under [PROTOCOL, Signature verification profile](https://protocol.dfos.com/spec#signature-verification-profile): `alg` is exactly `EdDSA`, a `crit` member rejects, an embedded header key (`jwk`, `x5c`, or any key-bearing member) rejects, and the signature check applies the canonical-scalar gate (`S < L`) and the 64-byte length check. Each family below gates on its own exact `typ`, and that check is normative: it is what keeps a JWS signed for one purpose from being presented as another. Every `typ` value and service type used here is registered in [PROTOCOL, Extension registry](https://protocol.dfos.com/spec#extension-registry). --- ## Sign in Sign In With DFOS (SIWD) lets any third-party application verify a user's DFOS identity. The third party produces a **challenge**; the user's key signs it; the third party verifies the signature against the user's identity chain, resolved from a relay, without contacting any DFOS platform server. The artifact is always the same: the canonical challenge bytes, signed as a JWS with `typ: "did:dfos:siwd"`. What varies is the **courier**, how the challenge reaches a key and the signature comes back: | Profile | Transport | Latency | Signer | | --------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------ | ----------------------------------------------------------------------- | | **A: web redirect** | Browser redirect to a hosted `/authorize` | Interactive (seconds) | The hosting platform's custodial key, or any signer the host integrates | | **B: sign-request mailbox** | [Signing mailbox](https://protocol.dfos.com/relay#signing-mailbox-capability-signing) deposit, poll, respond | Asynchronous (minutes to days) | The subject's signing agent, polling the subject's mailbox | A verifier cannot tell which courier delivered the artifact, and does not need to. Profile A is the hosted authorize path: it works against custodial identities, and it is where a relationship is established and where credential-returning scopes issue their credentials. Profile B carries the same artifact asynchronously once a deposit grant exists. ### Challenge schema The challenge is a JSON object: ```json { "domain": "3p.com", "nonce": "a8f2e93b...", "timestamp": "2026-04-13T15:30:00.000Z", "statement": "Sign in to 3P App", "did": "did:dfos:" } ``` | Field | Required | Description | | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `domain` | Yes | The requesting application's domain: a bare lowercase hostname, no scheme or port. Under profile A it MUST match the hostname of `redirect_uri`. | | `nonce` | Yes | Unique value generated by the third party, used for replay prevention. | | `timestamp` | Yes | Timestamp of challenge creation, in the [timestamp grammar](https://protocol.dfos.com/spec#timestamp-grammar), **floor-normalized to whole seconds** (`.000Z` millisecond component). | | `statement` | No | Human-readable description a signer MAY show the user; a signer is never obliged to render it. Requester-controlled text, see [Rendering the statement](#rendering-the-statement). | | `did` | No | If provided, binds the challenge to a specific DID. A signer MUST refuse to sign if its own DID does not match. | The schema is [Sign-In with Ethereum](https://eips.ethereum.org/EIPS/eip-4361)'s (EIP-4361, final) field set with `address` replaced by `did` and the chain fields dropped, so its rationales travel with its fields: `domain` is SIWE's phishing binding, `nonce` its replay defense. [CAIP-122](https://chainagnostic.org/CAIPs/caip-122) generalizes the same shape to Sign-in-with-X. Three divergences are deliberate: a DID is a self-certifying identifier, not a [CAIP-10](https://chainagnostic.org/CAIPs/caip-10) account, so there is no `chain-id`; there is no `version` member, because the envelope's `typ` scopes the artifact; and SIWE's `uri` has no slot, because its job is done by [redirect URI validation](#redirect-uri-validation) and the `domain` match rather than by a signed member. **Timestamps are whole-second.** Producers MUST normalize `timestamp` to the `.000Z` form (floor, never round). A strict signer validating this family rejects a sub-second timestamp as a schema violation. The challenge is signed as a JWS using a key from the user's identity chain. The JWS protected header MUST set `typ: "did:dfos:siwd"`, and verifiers MUST reject any other `typ`. The JWS `kid` header contains the DID URL of the signing key (`did:dfos:#`). ### Challenge canonical signing input The signer and the verifier MUST agree on the exact bytes that are signed. The **canonical signing input** is the challenge object serialized as minimal UTF-8 JSON (no insignificant whitespace) with its members in this fixed order, omitting any absent optional member: ``` domain, nonce, timestamp, statement?, did? ``` These bytes are simultaneously the JWS payload segment, the body base64url-encoded into profile A's `challenge` query parameter, and the octets a profile B sign-request carries in its `payload` field, so a holder of any encoding can confirm they describe the same object. Reference implementations of the byte contract are `siwdSigningInput(challenge)` in [`@metalabel/dfos-client/siwd`](https://www.npmjs.com/package/@metalabel/dfos-client) and its byte-twin in `dfos-protocol-go`. ### Profile A: web redirect The third party redirects the user's browser to a hosted `/authorize` endpoint; consent and signing happen there; the signed artifact returns by redirect. #### Finding the authorize endpoint ```typescript { id: string, type: "DfosAuthorizationServer", endpoint: string } // endpoint: bare URL string ``` The entry is a [service entry](https://protocol.dfos.com/spec#services) under the core's open type namespace, registered by this document. To a core verifier it is an unrecognized type: preserved verbatim, never structurally validated. Structural validation is a consumer obligation here. `endpoint` is the **canonical authorize origin URL**, the base URL at which a platform able to produce this subject's signature serves this profile's `/authorize` surface. A client that already holds the subject's DID SHOULD resolve the subject's identity chain and take the redirect target of step 1 below from the chain's `DfosAuthorizationServer` entry, falling back to its configured or out-of-band host when the entry is absent. Absence is ordinary and fully legal. **One entry, or none**: a services set carrying more than one `DfosAuthorizationServer` entry names no discoverable endpoint, and a consumer MUST fall back exactly as if the entry were absent, so ambiguity degrades to the fallback and never to a choice. An entry whose `endpoint` is missing or empty is ignored the same way. Non-normative: a platform MAY additionally serve RFC 8414-shaped authorization-server metadata at the named endpoint; nothing here reads it. #### 1. Redirect to authorize ``` https://app.dfos.com/authorize? challenge= &redirect_uri=https://3p.com/callback &scope=identity &client_did=did:dfos:<3p id> ``` | Parameter | Required | Description | | -------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `challenge` | Yes | Base64url of the canonical challenge bytes (see [Challenge schema](#challenge-schema)) | | `redirect_uri` | Yes | URL the host redirects to after signing | | `scope` | Yes | A space-separated set of requested scope tokens (the OAuth `scope` convention); each token MUST be a registered scope, see [Scopes and credentials](#scopes-and-credentials) | | `client_did` | When any requested scope returns a credential | The third party's own DFOS DID, the `aud` any returned credential is issued to | | `spaces` | Optional, with a space-level scope | Where the space-level tokens apply: `all`, or a comma-separated list of distinct 31-character space ids; see [Scopes and credentials](#scopes-and-credentials) | | `client_proof` | Loopback credential tier | The client's [ask proof](#the-ask-proof), a JWS over this request's own canonical challenge bytes under `typ: "did:dfos:siwd-ask"` | | `client_chain` | Loopback credential tier, unless resident | The client identity's [carried chain](#chain-residence), base64url of the JSON array of its verbatim operation JWS strings, genesis first | `client_did` is what makes a credential-returning scope well-posed: the credential is issued to a named DID. The host MUST display `client_did` (or an identity resolved from it) alongside `domain` on the consent screen. `domain`, not `client_did`, is the phishing-relevant binding, because nothing here proves that a DID controls a domain; `client_did` determines only who can exercise a returned credential, which the credential machinery enforces cryptographically. #### 2. Consent The host authenticates the user (existing session) and presents a consent screen describing what the third party requests: identity verification alone, or identity plus a scoped credential. The `statement`, if present, MAY be rendered; a host that renders it MUST do so under the [rendering rule](#rendering-the-statement). Declining to render requester-controlled prose is a legitimate host posture. When the request names a space-level scope without `spaces`, the consent screen is where the user chooses the places, and consent MAY narrow that set; the token set is never narrowed. #### 3. Signing A key from the user's identity chain signs the canonical challenge bytes. For a custodially-hosted identity, the host signs with the user's custodial key after consent. What is normative is that the signature is produced by a key effective in the identity's state and verifies under the [Signature verification profile](https://protocol.dfos.com/spec#signature-verification-profile); the custody mechanics are the host's. A host MAY integrate other signers behind the same consent screen. #### 4. Callback ``` https://3p.com/callback? jws= &did=did:dfos: ``` If a credential was requested via `scope`, it is delivered in the URL **fragment**, not the query string: ``` #credential= ``` The fragment never leaves the browser: it does not appear in server access logs, proxy logs, or the `Referer` header, so the grant does not land anywhere the redirect target's infrastructure would passively record. The receiving page's script reads it and forwards it to the application's backend over an intentional channel, typically a POST. A loopback application's local listener is the same shape: the fragment never appears in the HTTP request line its server receives, so the listener serves the receiving page and the page's script relays the full URL back over the local channel. The credential is proof-of-possession bound (see [API authentication](#api-authentication)) and is inert without the audience's key, so the fragment protects grant metadata, not the grant itself. The `jws` and `did` parameters remain in the query string; they are consumed server-side at the callback endpoint, which is exactly where a fragment could never reach. #### Loopback redirect targets A `redirect_uri` on the loopback interface (`http://localhost`, `http://127.0.0.1`, or `http://[::1]`, on any port and any path) is a valid target: the loopback rule of [RFC 8252 §7.3](https://www.rfc-editor.org/rfc/rfc8252#section-7.3), adopted with its reasoning that a local application cannot reserve a port, so any port is legal and the port is never part of the comparison. An application running on the user's own machine holds no domain, so the host consents to it under its own tier and says plainly what it is: software running on this device. At `scope=identity` the consent screen shows the literal delivery target and offers no application name and no `client_did`, because neither is proven. A `client_did` on a loopback authorize request is honored only under the [loopback credential tier](#loopback-clients), where the request itself proves control of that identity's keys, and is refused outright otherwise. The port is not part of the binding either, so the challenge's `domain` is the bare loopback host, an IPv6 literal unbracketed (`::1`, never `[::1]`, because the brackets are URL grammar rather than part of the name). The hosted `/authorize` path is where the signature is produced: the callback may go to a local port, but the user's key never does, and an application that needs only the user's identity does not need to hold a key to receive a proof. #### Redirect URI validation The host MUST establish that the `redirect_uri` belongs to the party named by `domain` before redirecting to it, and MUST refuse any target it cannot establish that for. An open redirector lets an attacker substitute their own callback URL to capture signed challenges. Exactly two admission paths satisfy the obligation, and the target itself decides which one applies: - **A web origin is admitted by its own well-known document.** A `redirect_uri` on any non-loopback origin MUST be validated by fetching the [app description document](#the-app-description-document) (`/.well-known/dfos-app.json`) from the redirect's own origin and matching the target against its `redirect_uris` allowlist. There is no out-of-band path: a host MUST NOT admit a web-origin target on any basis other than the origin's own served document. - **A loopback target is admitted as local software.** A [loopback target](#loopback-redirect-targets) asserts no domain at all and is consented to as software running on the user's device. At `scope=identity` it needs no backing; a credential-returning scope is honored only under the [loopback credential tier](#loopback-clients). The split is exclusive in both directions, and the exclusivity is normative. A host MUST refuse `client_proof` and `client_chain` on a request whose `redirect_uri` names a web origin: an application with an origin proves itself by serving its document. A host MUST NOT use any app description document to back a loopback request's `client_did`: the ask proof is the only thing that backs it. Outside the loopback case the `domain` field in the challenge MUST match the domain of the `redirect_uri`, and the host MUST reject requests where these diverge. The host redirects only to the literal `redirect_uri` the ask named, port included. Under profile B there is no redirect and no `redirect_uri`. The domain binding lives entirely inside the signed challenge, and the signer's obligation to render `domain` prominently is the corresponding control. ### Profile B: sign-request mailbox The third party wraps the canonical challenge bytes in a [sign-request envelope](https://protocol.dfos.com/relay#the-sign-request-envelope) (`payloadTyp: "did:dfos:siwd"`), deposits it in the subject's relay signing mailbox, and polls for the response. #### Flow 1. **Compose.** The third party, as envelope requester signing with its own current key, builds a sign-request: `subject` = the user's DID, `payloadTyp` = `"did:dfos:siwd"`, `payload` = base64url of the canonical challenge bytes. 2. **Deposit.** `POST /signing/v0/requests` at the subject's relay, authorized by a `mailbox:` / `deposit` credential, obtained at a prior profile-A consent via the [`deposit` scope](#scopes-and-credentials) or from a published open grant. 3. **Sign.** The subject's signing agent polls, applies the [signer obligations](#signer-obligations-for-this-family), and responds with the signed artifact. 4. **Collect.** The third party polls `GET /signing/v0/requests/{cid}/response` and receives the same JWS a profile-A callback would have delivered. Verification is identical. Discovery under this profile is identity-first: the deposit target comes from the subject's own chain, its `DfosRelay` services entries, never from configuration. #### The one-clock rule A sign-request carrying a SIWD challenge MUST set its `expiresAt` no later than the moment the composer's own verifier would reject the challenge's `timestamp` as stale. The envelope may not outlive the semantics of its payload. A composer using this profile sizes its challenge-acceptance window to the asynchronous latency it intends to allow, up to the envelope family's 7-day ceiling, and sets `expiresAt` to match. The redirect profile's conventional 5-minute window is a property of that profile's interactivity, not of SIWD. #### Signer obligations for this family A signer handling `payloadTyp: "did:dfos:siwd"` applies the full [signer obligations](https://protocol.dfos.com/relay#signer-obligations) with these family specifics: - **Strict schema.** The decoded payload MUST parse as a challenge object with no unknown members, `timestamp` in whole-second `.000Z` form, and, when `did` is present, `did` equal to the signer's own DID (refuse otherwise, before signing). This is the challenge binding: it keeps an attacker from substituting a different user's identity into a challenge intended for a specific user. - **Re-canonicalize and byte-compare** per the standard rule; the canonical form is the [challenge canonical signing input](#challenge-canonical-signing-input) above. - **Render `domain` prominently.** The domain is what the user is authenticating to; it is the primary phishing-relevant fact and MUST be shown. - **Render the statement as untrusted text**, see below. #### Rendering the statement SIWD is the one envelope-family payload that carries requester-controlled prose. A signer that renders the statement MUST render it visibly delimited as third-party-supplied text (quoted, boxed, or otherwise set off) and MUST NOT interleave it with the signer's own UI chrome, instructions, or security indicators. A signer MAY decline to render it at all. What is forbidden is the middle ground, showing requester prose as though the signer said it. #### The signing agent A subject with no self-held key cannot poll a mailbox, because poll authorization is key possession. A custodial platform holding a subject's keys MAY act as the subject's **signing agent**: it reads asks addressed to its custodial users, applies the signer obligations above in its own product surface, and signs with the custodial key on the user's approval. The obligations do not weaken. A subject who later enrolls a device key takes over polling with no change visible to any requester. The mailbox is poll-only; there is no push in the protocol. A platform that operates both a relay and its users' signing agent can close the latency gap out-of-band by notifying its agent to poll. That is an operational nicety, not protocol surface. ### Verifying a sign-in Verification is identical regardless of courier, and the JWS signature MUST be checked under the [Signature verification profile](https://protocol.dfos.com/spec#signature-verification-profile). 1. **Decode the JWS.** Extract the challenge payload, `kid` header (DID URL of signing key), and signature. Before any signature work, apply the header gates: the protected header `typ` MUST equal the exact string `"did:dfos:siwd"`; the protected header `alg` MUST equal the exact string `"EdDSA"`; a `crit` member MUST cause rejection; and an embedded header key (`jwk`, `x5c`, or any key-bearing member) MUST cause rejection. The key is never read from the header. 2. **Resolve the DID.** Fetch the identity chain from a DFOS relay and replay it to the chain head, then extract the public key matching the `kid` from the effective `authKeys` at that head. The basis is now, per [Time basis](https://protocol.dfos.com/spec#time-basis): a key rotated out MUST NOT verify, and a deleted identity MUST NOT verify. The protocol has no identity-revocation primitive; deletion is the condition a verifier checks, and an identity later restored to active verifies again. Credential-scoped revocation applies to the optional returned credential, not to the identity. > The head is the head **as the chosen relay has seen it**. A relay that has > not ingested the chain's newest operations serves an older head, so a > single-relay resolution can accept a key its subject has already rotated > out. Following one view is a choice of relay > ([PROTOCOL, Views](https://protocol.dfos.com/spec#views)). A verifier with > higher assurance needs SHOULD resolve from the relays the subject's own > identity state lists in its `services` entries. 3. **Verify the signature.** Ed25519 verification of the JWS against the resolved public key, with the canonical-scalar gate (`S < L`) and the 64-byte length check from the profile. 4. **Validate the timestamp.** Reject challenges outside the acceptance window the composer chose for the courier in use: conventionally 5 minutes under profile A, and under profile B the window the composer sized when it set the envelope's `expiresAt`. The bound runs in both directions: a verifier SHOULD also reject a timestamp in the future beyond small clock skew (the reference implementation allows 60 seconds), because a forward-dated challenge would otherwise outlive every acceptance window. 5. **Validate the domain.** Confirm the `domain` in the challenge equals the verifier's own domain (the bare hostname it serves). 6. **Validate the nonce, last.** Recover the expected nonce from the verifier's own prior state and confirm the challenge's `nonce` matches, under whichever [replay discipline](#replay-prevention) the grant requires. This step runs after every other check has passed: under the consumed discipline the nonce is spent by verification, and an invalid presentation must never spend a nonce its legitimate holder is still carrying. No DFOS platform server is contacted during verification. The third party only needs access to a relay to resolve the DID's identity chain. **A DID is an address, not a proof.** A bare `did:dfos` identifier is freely copyable and freely presentable by anyone who has seen it once, and the callback hands one to the relying party in the clear. What proves control is the JWS, and **verification of the JWS MUST happen wherever a session is granted**. A service that reads `did` from a callback, a request body, or a header and mints a session on the strength of the identifier alone has authenticated no one. A relying party that splits verification from session-granting across a trust boundary MUST carry the verification verdict across that boundary, not the DID. **Token lifetime.** Signed challenges are short-lived authentication proofs, redeemable once or once per flow channel under the [replay discipline](#replay-prevention) in force, never bearer tokens. Third parties SHOULD establish their own session after verification and discard the JWS. Credentials returned via `scope` carry an explicit `exp`; third parties MUST respect expiration and re-request when expired. ### Scopes and credentials Identity proof is inherent: the signed challenge itself proves DID ownership. `scope` is a space-separated set of tokens (the OAuth convention). `identity` alone asks for the proof and nothing more, and every other requested token additionally returns a [DFOS credential](https://protocol.dfos.com/credentials). Each token MUST match a registered scope from the table below; a request carrying an unregistered token MUST be refused whole, never partially honored, because a consent screen that silently drops tokens misstates what was asked. API tokens return **one** credential whose entries the coalescing rule below decides; tokens naming distinct resource types return one credential each. Consent is to the set: the consent screen describes every requested token. | Scope | Level | Returned credential | | ------------------ | ------- | ---------------------------------------------------------------------------------------------------- | | `identity` | | None, the signed challenge alone | | `read:` | | `{ "resource": "chain:", "action": "read" }`, issued to `client_did` | | `deposit` | | `{ "resource": "mailbox:", "action": "deposit" }`, issued to `client_did` | | `read:profile` | account | `{ "resource": "api:", "action": "read:profile" }`, issued to `client_did` | | `read:email` | account | `{ "resource": "api:", "action": "read:email" }`, issued to `client_did` | | `read:memberships` | account | `{ "resource": "api:", "action": "read:memberships" }`, issued to `client_did` | | `read:posts` | space | `{ "resource": "api:[/spaces/]", "action": "read:posts" }`, issued to `client_did` | | `write:upvotes` | space | `{ "resource": "api:[/spaces/]", "action": "write:upvotes" }`, issued to `client_did` | | `write:comments` | space | `{ "resource": "api:[/spaces/]", "action": "write:comments" }`, issued to `client_did` | | `write:posts` | space | `{ "resource": "api:[/spaces/]", "action": "write:posts" }`, issued to `client_did` | SIWD defines no resource grammar of its own. Resource forms, action vocabulary, and matching rules are the [credential spec's](https://protocol.dfos.com/credentials): `chain:` exact-match; `mailbox:` / `deposit` exact-match; `api:` and `api:/spaces/` equal-or-ancestor, with the enumerated action registry [below](#the-apihost-resource-and-its-actions). Each token is matched against this table's registered tokens first, and the parameterized `read:` form matches only when `` is a 31-character content id, so the literal API tokens are never ambiguous with it. **`read:`.** For content owned by the user's DID, the credential is issued by that DID: `iss` = the user's DID, `aud` = `client_did`, one attenuation covering the requested chain. For content owned by a space (a separate DID), the credential is issued by the **space's DID**, mediated by the host: the user consents, the host verifies the user's membership and permissions within the space, then issues from the space's DID. The envelope, signing, CID derivation, and validity bounds are exactly as the credential spec defines. SIWD adds no fields and no separate credential format. **`deposit`.** Consenting to this scope issues a `mailbox:` / `deposit` credential to `client_did`, rooted at the subject and signed by the subject's key, custodially by the host or by the subject's own key under self-custody. It is how a third party earns the right to use profile B: a first profile-A authorization with `scope=deposit` establishes identity and grants deposit, and from then on the third party never redirects again. Revoking the deposit credential severs the relationship, because the relay's deposit gate re-checks revocation on every deposit. **API scopes.** An API scope returns an [`api:`](#the-apihost-resource-and-its-actions) credential for the hosting platform's own API host: `iss` = the user's DID, `aud` = `client_did`, `att` decided by the two dimensions of the ask. `scope` names the actions, and `spaces` names the places the space-level actions apply to. Each wire scope token maps 1:1 to an API action token, and every API action token registered [below](#the-apihost-resource-and-its-actions) is a SIWD scope the same way, at the same level. For these scopes `client_did` is **required**, and the verifier MUST apply the **consumed** [replay discipline](#replay-prevention), because success yields an artifact redeemable outside the presenting channel. The credential alone opens nothing: exercising it requires a per-request [request proof](#the-request-proof) signed by the `client_did`'s key. **`spaces`.** `all`, a comma-separated list of distinct 31-character space ids (at most 31), or absent. It is well-posed only when `scope` carries a space-level token: a request carrying `spaces` without one, or carrying a malformed value, is refused whole. Present, the requester has named the places and the consent screen is locked to them. Absent, the user chooses the places at consent, and consent MAY narrow the ask to a subset of the spaces the user is in, or widen it to all of them; the token set is never narrowed. A named space the user is not in is issued anyway: the entry is inert against live standing, and a host renders such an id raw, never with a name. `all` is a standing choice, since the host form covers every space the subject is in, spaces joined later included, and the consent screen says so. **Coalescing.** Account-level tokens, and space-level tokens granted for all spaces, coalesce into **one** `api:` entry carrying the combined action list. Each chosen space is one `api:/spaces/` entry carrying the space-level token set. One credential per authorization, never one per token, so revoking it severs the whole grant at once, and the entry count never exceeds the chosen spaces plus one. **The artifact is the answer.** The returned credential's `att` states what was granted. A requester reads it and never assumes the ask was honored whole, because consent may have narrowed the places. **One app, one standing grant.** Re-consent for the same subject and `client_did` revokes the prior credential and issues the new one in its place. Narrowing a standing grant is a re-consent. ### Replay prevention The `nonce` field is the primary replay defense; the `timestamp` bound is secondary, and a stale challenge SHOULD be rejected even with a valid nonce. The third party MUST generate a cryptographically random nonce per authorization request, expire unverified nonces with their acceptance window, and validate the presented nonce under exactly one of two disciplines. Which discipline is admissible is decided by what success grants; SIWD has no token endpoint, so the obligation lands on the verifier that redeems the artifact. **Flow-bound verification** is sufficient when success grants only a session with the presenting channel (`scope=identity` browser login, and nothing more). At mint time the verifier binds the nonce to the agent that initiated the flow, through state the presenter cannot populate with a value of its choosing: a server-side login session keyed by an unguessable identifier, or, statelessly, the nonce **sealed under the verifier's own key** in an `httpOnly` cookie scoped to the callback origin and expiring with the acceptance window. At verification the verifier recovers the expectation from that binding, compares it to the challenge's nonce, and clears the binding on success. The guarantee is exactly this: the artifact redeems only through the channel that initiated the flow, inside the timestamp window. It is not global single-use, and under this discipline a stale challenge MUST be rejected, because the acceptance window is the only server-enforced bound on a captured pair. A seal is a real MAC or it is nothing: HMAC-SHA-256 (or authenticated encryption) under a key used for no other purpose, full-length tag, constant-time comparison, and an encoding that cannot confuse the sealed value with its tag or with a seal minted for a different purpose. Sealing the whole expectation (nonce, purpose, and an explicit expiry) instead of the bare nonce is the stronger form of the same move. **Consumed verification** is REQUIRED whenever success yields anything beyond a session with the presenting channel: every credential-returning scope, any token or grant redeemable outside the originating channel, any success that triggers a side effect beyond establishing the session itself, and all of profile B (a mailbox flow has no presenting channel to bind). The verifier stores each minted nonce server-side, bound to its authorization, and consumption is an **atomic check-and-delete performed as the final verification step**, after signature, key currency, timestamp, and domain have all passed. An artifact whose nonce does not match an outstanding value, or has already been consumed, MUST be rejected. The atomicity is real, not stylistic: a check-then-delete written as two operations lets two concurrent replays both win. A profile-A loopback application is its own verifier, and the expectation lives in the process that minted it, held in memory and discarded after one comparison. That is consumed verification with a store of size one, and it is the discipline a loopback verifier applies. **Either way, the expectation MUST be something only the verifier could have produced.** A nonce handed to the verifier by the party presenting the artifact, in a callback parameter, a request body, or a header, is not a replay defense at all: an attacker replaying a captured JWS reads the nonce out of the artifact itself. A bare cookie is the same trap in a costume, since cookies are presenter-supplied on every request. What makes either discipline bind is that the expectation is recoverable only from the verifier's own prior act. ### The app description document An application's `/.well-known/dfos-app.json`, served over https from its own origin, is its self-description: the file the admission path of [redirect URI validation](#redirect-uri-validation) fetches, where the domain vouches for itself. Serving the file **is** the registration. There is no developer portal and no client secret; domain control is the credential. The path is one of the corpus's four [RFC 8615](https://www.rfc-editor.org/rfc/rfc8615) well-known suffixes, tracked in the [extension registry's external-registrations table](https://protocol.dfos.com/spec#external-registrations). Alone among the four it carries `.json`: the document is the one member of the family designed to be served as a static file from hosting the application does not program, and a file extension is how static hosts map a file to its media type. The registered suffix is the full string, extension included. ```json { "name": "3P App", "client_did": "did:dfos:<3p id>", "redirect_uris": ["https://3p.com/callback"], "identity_chain": ["", ""] } ``` | Member | Required | Description | | ---------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | No | Display name, the application's own claim about itself. Nothing here vouches for it; a consumer that renders it renders a claim, and `domain` remains the phishing-relevant binding. Absent a name, a consumer leads with the domain it already verified. | | `redirect_uris` | Yes | Exact-match allowlist of the redirect targets this origin will accept callbacks at, the strings a host compares a `redirect_uri` against under [redirect URI validation](#redirect-uri-validation). Exact means exact: scheme, host, path, trailing slash included. | | `client_did` | No | The application's own DFOS DID. Optional at `scope=identity`; required whenever a credential-returning scope would name this application as audience. A host MUST refuse a web-origin authorize request naming a `client_did` the origin's document does not back, because admission runs through the document. | | `identity_chain` | No | The application identity's full operation log, carried in place, see below. | The member set is closed: this table is the registry. Every member is security-relevant, and a misspelled `redirect_uris` must never silently degrade into "no allowlist at all", so a consumer MAY treat a document carrying unrecognized members as invalid, and new members are added by amending this table. A document missing a required member is not an app description at all, and consumers MUST treat it as invalid; a required member that is present but empty (an empty `redirect_uris` array) is missing in everything but syntax, and consumers MUST treat it the same way. An optional member that is present but empty, a zero-length `name` or an empty `identity_chain`, is likewise malformed rather than merely absent. A document that omits every optional member is valid. Origins SHOULD serve this document with `Access-Control-Allow-Origin: *`. The document is public by construction, so the header withholds nothing, and its absence blocks browser-based tooling from reading a document that is already world-readable to every non-browser client. A JSON Schema for **structural** validation of this document is published at [`https://schemas.dfos.com/dfos-app/v1`](https://schemas.dfos.com/dfos-app/v1) (source: [`schemas/dfos-app.v1.json`](https://github.com/metalabel/dfos/blob/main/schemas/dfos-app.v1.json)). It mechanically checks this table and nothing more: chain validity, the genesis-DID equality, and per-operation size are consumption-time checks that schema cannot express, and this document is normative wherever the two could be read to disagree. #### `identity_chain`: chain carriage An application named by `client_did` needs its identity chain resident somewhere a verifier can resolve it, and a freshly minted application identity may be resident nowhere at all. The optional `identity_chain` member carries the chain in the one document whose origin already vouches for the application. - **Format.** An array of identity-operation JWS strings: the **application identity's** (not any user's) **full ordered operation log, genesis first**, the same ordered log a relay serves and a verifier replays, carried here as bare JWS strings. Never a fragment or a suffix; the whole chain from genesis. - **Self-consistency.** If `identity_chain` is present, `client_did` MUST be present (structural, checked at every parse) and MUST equal the DID derived from the chain's genesis operation (cryptographic, checked wherever the chain is consumed). A consumer that consumes the chain MUST reject a document where they disagree, whole-document, never ingesting the chain while ignoring the mismatched `client_did`. A processor that leaves the chain unconsumed is not obliged to verify it first, since consumption is optional; what it MUST NOT do is present or treat `client_did` as chain-backed without having verified. - **Carriage cap.** The chain MUST NOT exceed **100 operations**. The protocol core [does not bound chain length](https://protocol.dfos.com/spec#size-and-cardinality-limits); 100 operations is this transport's bound. An identity that has outgrown the cap has outgrown carriage: its chain belongs on relays, and the document omits the member. A consumer encountering a longer chain MAY refuse the document unexamined, and no consumer is obliged to process past the hundredth operation. Per-operation size is governed by the protocol's operation-size cap, and nodes MAY apply local ingress byte limits to the document fetch. - **Consumption.** Verifiers and relays MAY fetch, verify, ingest, and re-serve a carried chain on encounter, with **no registration or approval precondition**. The chain is self-certifying: replayed from genesis it is as authentic as any relay resolution. What carriage never adds is trust beyond the signatures, and whether a consumer ingests, retains, or ever re-serves what it encountered is its own policy (see [Carried identity chains](#carried-identity-chains)). ### Loopback clients A loopback client is software on the user's own machine: a CLI, a desktop application, an agent. It holds no domain, so the [app description document](#the-app-description-document) is a registration it can never perform. For identity alone that costs nothing. To receive a **credential** it must prove the one thing local software can prove: key control. The **loopback credential tier** allows a `client_did` on a loopback authorize request **iff the request proves control of that identity's current keys**, under the rules below. Everything downstream is unchanged: scopes and multi-scope sets, the one-credential-per-resource coalescing, and revocation all work exactly as in the hosted case. #### Chain residence The request MUST carry the client's identity chain, unless the DID is already **resident** on the verifying host's store, in which case carriage is unnecessary. A carried chain travels under exactly the [chain-carriage rules](#identity_chain-chain-carriage) of the app description document: the full ordered operation log, genesis first; the DID derived from the genesis operation MUST equal `client_did`, and a request where they disagree MUST be refused whole; the 100-operation carriage cap; verification under the [Signature verification profile](https://protocol.dfos.com/spec#signature-verification-profile) to the chain head. A host that ingests what was carried applies the [first-encounter discipline](#carried-identity-chains) unchanged: carriage proves which party sent the chain, never that the party controls the identity. The chain travels as the **`client_chain`** authorize parameter: base64url of the JSON array of the verbatim operation JWS strings, genesis first, the `challenge` parameter's own grammar, so one decoder shape serves both. A host MAY bound the parameter's encoded size (the reference host refuses carriages past 8KiB). What is normative is that the chain arrives with the ask and verifies before any consent is rendered. #### The ask proof The request MUST carry a **client proof**, as the **`client_proof`** authorize parameter: a JWS over the exact [canonical challenge bytes](#challenge-canonical-signing-input) of the request's own challenge, signed by a key effective in the client identity's **`authKeys`** at the chain head, with `alg: "EdDSA"`, `kid` the DID URL of the signing key, and protected header `typ: "did:dfos:siwd-ask"`. The host MUST verify it under the [Signature verification profile](https://protocol.dfos.com/spec#signature-verification-profile) against the client chain, carried or resident, **before any consent is rendered**: possession is proven at ask-time, not just at spend-time. The challenge's optional `did` member binds the subject, and the sign-refusal rule it triggers belongs to the `did:dfos:siwd` family; the ask proof is the client speaking, not the subject. #### Consent provenance The host MUST NOT display a domain for a loopback client. There is none, and implying one would manufacture exactly the binding this tier cannot check. The consent screen displays the client DID whose keys were proven, and an honest statement of what is and is not established: the software runs locally on the user's device, its origin and authorship are unverifiable, and what the proof establishes is only that the asking party controls the named identity's keys. The `domain` slot of the hosted consent has no loopback equivalent, and the consent copy MUST NOT suggest one. #### Expiry ceiling A credential minted to a loopback client MUST carry a hard expiry ceiling enforced by the issuing host; RECOMMENDED **14 days**. The ceiling bounds the blast radius of what this tier accepts, key control without provenance, while revocation remains the user's "disconnect this app". ### Carried identity chains A chain that arrives by [carriage](#identity_chain-chain-carriage) is verified exactly like a chain that arrives from a relay. What carriage changes is operational: the consumer holds identity state fetched on its own clock from an origin the application controls, and five disciplines follow. - **Monotonicity is compared on operations, not on derived state.** A consumer that retains observed chain state SHOULD compare a fresh fetch against it on the **ordered operation-log CIDs**. A fetch that is a proper prefix of previously observed state SHOULD be ignored: serving yesterday's shorter chain is the rollback move, resurrecting a rotated-out key by omitting the rotation. - **Divergence is signed, and acceptance is discretionary.** Two chains sharing a prefix and disagreeing after it both verify. Which branch, if either, a consumer accepts is operator discretion, and an observed divergence SHOULD be logged. Nothing outside the consumer picks a branch: both are views of the same identity, and following one is a choice of source ([PROTOCOL, Views](https://protocol.dfos.com/spec#views)). - **Staleness runs in both directions.** A carried chain is a snapshot at fetch time. The consumer's re-fetch cadence bounds new-key usability and revoked-key death alike, so an operator choosing a cadence is choosing both bounds at once. - **Serving is discretionary, and so is stopping.** Nothing obliges any relay or verifier to ingest a carried chain, to retain it, or to keep re-serving it; removal, including abuse removal, is operator discretion. Ingestion is permissionless for the application; retention is policy for the operator. - **First encounter proves nothing about ownership.** Identity operations are public data, so encountering a chain in an app description proves which origin served it, never that the origin controls the identity the chain derives. A consumer that ingests carried chains into a store that also holds identities under its own authority MUST refuse or segregate a carried chain whose derived DID it already holds **under that authority**; a chain already held as previously carried state is an ordinary re-fetch, governed by the monotonicity discipline above. Monotonicity begins only after a first accepted state, so it cannot defend the first encounter. --- ## API authentication Proof-of-possession authentication for DFOS-gated HTTP APIs: one envelope family, two artifacts. A **request proof** is a short-lived JWS, signed by the key of the party a [DFOS credential](https://protocol.dfos.com/credentials) was issued to, that binds one exact HTTP request (method, host, path, body) to that credential, right now. The credential says what its holder may do; the proof says the holder is the one doing it, and doing exactly this. An **identity proof** is the same envelope minus the credential: it binds the same exact request to a bare DID, proving only who is asking. Neither is a bearer token. The nearer standard neighbor is [HTTP Message Signatures (RFC 9421)](https://www.rfc-editor.org/rfc/rfc9421): the proof's `{method, host, path, bodyHash}` binding is almost member for member a 9421 signature over `("@method" "@authority" "@path" "content-digest")`. It is not adopted because its wire rides [RFC 8941](https://www.rfc-editor.org/rfc/rfc8941) Structured Fields plus [RFC 9530](https://www.rfc-editor.org/rfc/rfc9530), two more parsers in five languages, for no binding this envelope's single canonical JSON payload does not already make; it defines no slot for the `credentialCID`; and it leaves key resolution out of scope. The grant-exercising half of the shape is [DPoP (RFC 9449)](https://www.rfc-editor.org/rfc/rfc9449), which binds `htm` and `htu` and does not cover the request body; the body binding here is a strengthening of that shape. What this family is not: not a session (there are no cookies and no server-side session state, and every request is authenticated by its own proof); not a bearer token (the `Authorization` scheme is deliberately not `Bearer`); not relay surface (a proof is never ingested, never gossiped, never content-addressed); and not a signing family (the presenter signs proofs with its own key, so there is no other party to ask and nothing to courier). ### The request proof #### JWS header ```json { "alg": "EdDSA", "typ": "did:dfos:request-proof", "kid": "did:dfos:nzkf838efr424433rn2rzkdv8h7t9ae#key_r9ev34fvc23z999veaaft83nn29zvhe" } ``` | Field | Value | Description | | ----- | -------------------------- | --------------------------------------------------- | | `alg` | `"EdDSA"` | Ed25519 signature algorithm | | `typ` | `"did:dfos:request-proof"` | Protocol-specific type discriminator | | `kid` | DID URL | `did:dfos:#`, identifies the signing key | **There is no `cid` header.** Content addressing exists in this envelope family to make artifacts referenceable. A request proof is referenced by nothing: it is never stored, never revoked, and correlates to nothing but the request it rides. The content-addressed artifact in this exchange is the credential, and the proof binds to it by carrying the credential's CID **in its payload**, under the signature. **`kid` names the credential's audience.** The `kid` MUST be a DID URL containing `#`, and its DID portion MUST equal the `aud` of the presented leaf credential. This is the possession being proven. **Key resolution is at the basis, which is now.** The presenter's signing key is resolved against the chain head, so rotated-out keys are rejected and a deleted presenter's proofs are rejected. Rotation is how a presenter whose key is compromised stops that key from minting proofs in its name. Any effective key role (auth, assert, controller) may sign. #### Payload ```json { "method": "GET", "host": "api.dfos.com", "path": "/v1/profile", "bodyHash": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", "credentialCID": "bafyrei...", "iat": 1772841600 } ``` | Field | Type | Required | Description | | --------------- | ------- | -------- | -------------------------------------------------------------------------------------------------------- | | `method` | string | yes | The HTTP method, uppercase (`GET`, `POST`, …) | | `host` | string | yes | The API's lowercase authority: bare hostname on the default HTTPS port, `host:port` otherwise; no scheme | | `path` | string | yes | The exact origin-form request target, path plus query string, byte for byte | | `bodyHash` | string | yes | Unpadded base64url of the SHA-256 of the raw request body octets | | `credentialCID` | string | yes | CID of the leaf credential presented alongside this proof | | `iat` | integer | yes | Issued-at, unix seconds (positive integer) | All six canonical members are required and none is optional, so the canonical form below carries no absent-member ambiguity. Additive members follow them, appended after the canonical order in lexicographic member-name order, and a verifier ignores an unregistered one per the protocol's MUST-ignore-unknown rule. One additive member is registered: | Member | Type | Required | Description | | ------ | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `jti` | string | no | Per-request uniqueness: a non-empty string of at most 256 UTF-8 bytes, RECOMMENDED at least 128 random bits, unique per presenter inside the freshness window. A write-shaped route requires it ([API security notes](#api-security-notes)) | A present `jti` that violates its schema invalidates the proof whether or not the route requires one. The example above is illustrative, with `credentialCID` elided; the normative bytes are the reference vectors, not this snippet. **`host` is an authority, port included when non-default.** `api:` surfaces are HTTPS surfaces, and the scheme is never in the value. A deployment on the default port (443) uses the bare hostname (`api.dfos.com`). A deployment serving the API on a non-default port MUST include it (`api.example.org:8443`), in both the proof's `host` and the [`api:` resource string](#the-apihost-resource-and-its-actions), so the port is part of the binding whenever it is load-bearing. Without this, one hostname serving two API origins on two ports would collapse them into one audience. The verifier's configured identity supplies the expected value; it is never read from the request. **`path` is the wire string, not a normalization.** The value is the origin-form request target exactly as the request line carries it: the path, plus `?` and the query string when one is present, byte for byte. It MUST begin with `/` and MUST NOT contain a fragment. There is no canonicalization: no percent-decoding, no query-parameter reordering, no trailing-slash equivalence. The presenter constructs the request and the proof from the same string, and the verifier compares against the request target it actually received. A deployment whose infrastructure rewrites paths before the verifier sees them must compare against the original request line. **`bodyHash` covers the application body octets**, the bytes the sender handed its HTTP client, which the verifier obtains after reversing transfer encoding (chunked) and content encoding (`Content-Encoding`, for example gzip). It is deliberately not the octets on the wire, so a transparent compression layer on either side does not break a binding neither party intended to make over compressed bytes. A request with no body hashes zero octets, whose digest is the constant `47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU`; there is no absent-member form for bodyless requests, so every proof is checked the same way. The encoding is the **canonical** unpadded base64url (RFC 4648 §5, no `=` padding, zero trailing bits) of the 32 digest bytes, and the value is compared as a **string** against the verifier's own re-encoding of the digest it computes, so a padded or otherwise non-canonical spelling of the right bytes is rejected, not normalized. A verifier MUST NOT decode the member and compare 32 bytes. Two obligations follow for a body-bearing request. First, a verifier MUST hash the **original** octets: where a framework or serverless runtime has already consumed the raw body and offers only a parsed representation, buffer them before the parser or read the platform's raw-body handle. A verifier MUST NOT hash a re-serialization of the parsed body, which would convert a byte binding into a semantic one. Second, because reversing `Content-Encoding` means decompressing attacker-controlled bytes **before** the proof signature is checked, the body cap the [algorithm](#verification-algorithm) requires is a bound on the **decoded** octet count, and decoding MUST abort at the cap. **`iat` and no `exp`.** The proof carries when it was made and nothing about how long it lives; the verifier owns the freshness window. A presenter-chosen `exp` would let the presenter widen the replay window unilaterally, and the party bearing the replay risk sets the bound. #### Proof canonical signing input The signer and the verifier MUST agree on the exact bytes signed. The **canonical signing input** is the payload object serialized as minimal UTF-8 JSON (no insignificant whitespace) with its members in this fixed order: ``` method, host, path, bodyHash, credentialCID, iat ``` Additive members follow, `jti` among them, in lexicographic order of member name. These bytes are the JWS payload segment. **Minimal JSON, HTML escaping OFF.** `path` routinely carries `&` and admits `<` and `>`. The canonical serialization emits these three octets **literally** (`&`, `<`, `>`) and MUST NOT apply the HTML-safe escaping that some JSON encoders default to, which would instead emit each as a JSON `\u`-escape (`\u0026`, `\u003c`, `\u003e`, the `encoding/json` default), changing the signed bytes. This matches JavaScript's `JSON.stringify`; the Go byte-twin MUST disable it (`Encoder.SetEscapeHTML(false)`). `iat` is emitted as a bare JSON integer (no fraction, no exponent). Because the presenter self-signs and the verifier checks the signature over the **received** payload bytes, the verifier does not re-canonicalize a request proof; the canonical rule binds **producers**, so that a TS signer and a Go signer emit the identical proof from identical inputs and the vectors byte-compare. #### HTTP carriage A credential-gated request carries two headers: ``` Authorization: DFOS X-Credential: ``` The `Authorization` scheme is the token `DFOS`, not `Bearer`, because the proof is not a bearer token and naming it one invites bearer handling (logging, caching, forwarding) that this artifact exists to make useless. The scheme is matched **case-insensitively**, per [RFC 9110 §11.1](https://www.rfc-editor.org/rfc/rfc9110#section-11.1), and is separated from the token by one or more spaces, with surrounding optional whitespace ignored. The token itself, a JWS compact serialization, is case-sensitive and is not further decoded at the header layer. `X-Credential` is the same header the [relay content plane](https://protocol.dfos.com/relay) uses for per-request credentials; the leaf token embeds its full delegation chain in `prf`, so one header carries the whole grant. A verifier MUST respond `401` when either header is missing or malformed, and SHOULD include a `WWW-Authenticate: DFOS` challenge on that response. The challenge header is SHOULD rather than MUST because common serverless front doors rewrite it in transit: an AWS-fronted deployment may deliver it as `x-amzn-remapped-www-authenticate`, or a response path may drop it entirely. Clients MUST NOT depend on the challenge header for protocol behavior; the response status code, interpreted through the verdict mapping below, is the normative machine signal. This carriage is normative for the `api:` resource family: every host that serves it, canonical deployment or fork, authenticates the same way. #### Size bounds | Bound | Value | Applies to | | ----------------- | -------------------------- | ---------------------------------- | | request-proof JWS | **4096 bytes** | the serialized proof token | | credential JWS | **262144 bytes** (256 KiB) | the credential spec's existing cap | Verifiers MUST reject either token over its cap **before any decode**, at the header layer. Both bounds are validity-determining and identical across implementations. The credential cap is [CREDENTIALS](https://protocol.dfos.com/credentials)'s own, restated rather than redefined. The credential cap is what the **protocol** accepts; what a deployment's **transport** accepts is usually far less, since CDN and serverless header ceilings commonly sit in the 8 to 20 KB range. That gap is deployment policy: a deployment whose grants outgrow its header budget raises the budget. ### Verification algorithm To verify a credential-gated request, given the two tokens, the received request, a way to resolve identities, a revocation source, and the current time: 1. **Size.** Reject a proof token over **4096 bytes** or a credential token over **262144 bytes**, before any decode. 2. **Decode** the proof JWS and apply the [Signature verification profile](https://protocol.dfos.com/spec#signature-verification-profile) header gates: `typ` MUST be exactly `did:dfos:request-proof`, `alg` exactly `EdDSA`, a `crit` member rejects, an embedded key member (`jwk`, `x5c`, …) rejects. A missing or non-string `typ` or `kid` rejects; the `kid` MUST contain `#`. 3. **Payload schema.** All six members present: `method` a non-empty uppercase string, `host` a non-empty lowercase string, `path` a string beginning with `/`, `bodyHash` a string that is the **canonical** unpadded base64url of exactly 32 bytes (a spelling that does not re-encode to itself, padded or with non-zero trailing bits, rejects here), `credentialCID` a non-empty string, `iat` a positive integer. A present `jti` MUST be a non-empty string of at most 256 UTF-8 bytes. Unknown members are ignored. 4. **Freshness.** Compute `now` as **integer Unix seconds** (floor), the same basis as `iat`, so the boundary does not turn on sub-second precision. Two bounds, both verifier policy: the proof's **age** `now − iat` MUST NOT exceed the acceptance window **W** (RECOMMENDED **60 seconds**), and its **forward skew** `iat − now` MUST NOT exceed a clock-skew allowance **S** (RECOMMENDED **60 seconds**). The binding cap is on their **sum**, `W + S`, the total span over which any one proof is accepted and therefore its worst-case replay window, which **MUST NOT exceed 300 seconds**. Splitting age from skew is what makes the sum meaningful: a single symmetric `|now − iat| ≤ W` conflates them, so a proof forward-dated by the full window is replayable for `2W`. A skew beyond `S`, an age beyond `W`, or a configuration with `W + S > 300` all reject. 5. **Request binding.** `method` equals the received request's method; `host` equals **the verifier's own configured authority** for the route being served, a value the deployment holds (hostname, plus port when non-default), NOT one read from the request. `Host`, `X-Forwarded-Host`, and the request URL's authority are all attacker-supplied and MUST NOT be the comparison source. A verifier serving several authorities selects the expected one from its own configuration for the matched route or virtual host, never from a request header. `path` byte-equals the received origin-form request target; `bodyHash` equals the verifier's re-encoding of the SHA-256 of the received body octets (zero octets when there is no body). Any mismatch rejects. 6. **Resolve the presenter** named by the `kid`'s DID to its identity state at the chain head. Unresolvable → **unverifiable**. Deleted → reject. No key matching the `kid` fragment effective at the head → reject. How the resolver came to hold that state is the deployment's concern; for an application identity it MAY have been ingested earlier via [chain carriage](#identity_chain-chain-carriage). Resolution here is a local lookup: the canonical deployment never fetches an application's well-known document at verify time. 7. **Signature.** Verify the proof JWS under that key, with the profile's canonical-scalar gate (`S < L`) and 64-byte length check. **A valid proof signature is the gate to the credential work below**: steps 8 to 11 do unbounded and network-touching work, and a verifier MUST NOT perform them for a request whose proof signature has not verified. 8. **Credential chain.** Verify the presented credential in full under [CREDENTIALS](https://protocol.dfos.com/credentials): signatures, schema, CID integrity, linear delegation, depth, audience linkage, monotonic attenuation, with `exp` evaluated against the basis (now, this being an ephemeral presentation) and **revocation checked at every level** against the verifier's current knowledge. 9. **Credential binding, and no public audience anywhere.** Re-derive the leaf credential's CID (its canonical base32 CIDv1 string, `bafyrei…`) from its parsed payload; it MUST equal the proof's `credentialCID`, compared as that canonical string form. The leaf's `aud` MUST be a named DID equal to the `kid`'s DID portion. Further, **no credential in the presented chain, leaf or any parent, may carry `aud: "*"`**. A public parent would let anyone self-issue a byte-identical leaf audienced to their own key, present the public parent as its `prf`, and pass steps 6 to 8 with a key they own, which is a complete proof-of-possession bypass. 10. **Subject selection.** The chain's root `iss` is the DID whose data the request serves. It is not checked against an externally-known resource owner, because for the [action registry](#the-apihost-resource-and-its-actions) there is none: `read:profile` serves the profile of exactly the DID that rooted the credential, and the credential is what selects that subject. A route parameter never does. 11. **Attenuation coverage.** The route's **required resource** is `api:` for an account-level route and `api:/spaces/` for a space-addressed route, where `` is **the verifier's own configured authority** (the same value step 5 binds, never a request-supplied one) and `` is resolved by the deployment's own routing, never read from a request-supplied resource string. Some `att` entry on the leaf MUST cover the required resource under the [`api:` coverage rule](#the-apihost-resource-and-its-actions) (the host form covers every space at the host; a space form covers only itself), and its canonical action set MUST contain the route's required action token. Every step MUST pass. Two ordering rules are load-bearing; the rest is free. **(a)** The proof signature (step 7) MUST verify before any credential-chain work (step 8 onward). **(b)** Body hashing (the `bodyHash` half of step 5) runs after the header, freshness, and non-body binding checks (steps 2 to 4 and the method/host/path of step 5), and only up to the decoded-body cap the deployment sets (**413** over it, a bound on decoded octets, aborting decode at the cap). Everything else may be ordered for efficiency. No step consumes or mutates state. **Verdicts are machine-distinguishable**, as everywhere in the envelope family: **invalid** ("checked and failed") versus **unverifiable** ("could not check", an unresolvable presenter, an unreachable revocation source). The verdict class is the outer distinction and takes precedence over the step at which it arose: **unverifiable → 503** regardless of whether the resolution failure surfaced in the proof phase (step 6) or the credential phase (step 8), because a transient resolution failure is the server's condition and MUST NOT be reported as a credential judgment. An **invalid** verdict then maps by phase: **401** for a proof-layer failure (steps 1 to 7), carrying the `WWW-Authenticate: DFOS` challenge where the deployment's response path preserves it, **403** for a credential-layer failure (steps 8 to 11). A verifier reports a step 11 miss, a valid credential that does not reach this route's resource or action, distinctly from a failed chain, so a route offering [optional authentication](#requirement-combinations) serves its anonymous projection on the former and still refuses the latter; on the wire both are 403. A **replayed** verdict, raised only by a route that requires `jti` ([API security notes](#api-security-notes)), maps to **409**: the proof was checked and is valid, and its uniqueness is already spent. Revocation in the verify path, the freshness window, and at-basis credential expiry are the difference between this design and a bearer token with extra steps. A verifier that skips step 8's revocation check has removed the user's only timely lever over a standing grant. ### The identity proof Not every DFOS-gated request exercises a grant. A caller reading its own data, a peer announcing itself, or a depositor a service admits on reputation rather than delegation needs the resource to know **who is asking**, with nothing to look up and nothing to revoke. The **identity proof** is the request proof's credential-less sibling: the same canonical envelope, the same freshness and binding obligations, no credential anywhere. - **`typ` is `"did:dfos:identity-proof"`**, a distinct registered value, because "possession of a grant's audience key" and "possession of a bare identity's key" are different claims. A route that requires a credential rejects an identity proof at the header gate, and the reverse. - **Payload** is the request proof's minus `credentialCID`: five members, all required, canonical order `method, host, path, bodyHash, iat`, under the same member rules, the same registered additive member `jti`, and the same [canonical signing input](#proof-canonical-signing-input) discipline (minimal JSON, HTML escaping off, `iat` a bare integer). - **Carriage** is the same `Authorization: DFOS ` header. On this family's own surfaces there is **no `X-Credential`**: an identity proof has no `credentialCID` to bind, so on any route where `X-Credential` means "the credential backing this proof", presenting one alongside an identity proof is malformed (**401**), and a verifier MUST NOT pick one of the two claims. A resource whose own specification assigns `X-Credential` a **different role**, a resource-authorization artifact evaluated by that resource's own machinery as the [relay content plane](https://protocol.dfos.com/relay#blob-download-get-contentcontentidblobref) does on non-creator blob reads, MAY accept it alongside an identity proof: the proof is the authentication half, the credential the authorization half. Which meaning a route serves is fixed by that route's specification, never inferred per request. - **Verification** is the [algorithm](#verification-algorithm)'s proof phase exactly (size cap, header gates with this `typ`, payload schema of five members, freshness, request binding against the verifier's **own configured authority**, presenter resolution at the head, signature) and stops there. Steps 8 to 11 do not exist for this artifact. **The signer is the principal.** The `kid`'s DID is who the request is from; what that DID may do is the resource's local policy, and this document says nothing about it. - **Verdicts** map as the proof phase always has: **401** invalid, **409** replayed, **503** unverifiable. There is no 403 tier, because nothing credential-shaped can fail. - **Replay** is bounded exactly as for the request proof: within-window byte-identical replay is the accepted bound for read-shaped uses, and a deployment gating **write-shaped** uses of an identity proof MUST apply the same [`jti` discipline](#api-security-notes). The signer is the principal, so what a proven identity may write is the resource's local policy; a deployment SHOULD refuse a presenter key whose only effective role is `controller`, since that key is a continuity instrument. An identity proof is not a session, not a login artifact ([sign in](#sign-in) is the challenge-response seam for establishing a relationship), and not an authorization (a resource that wants delegable, attenuable, revocable grants uses [credentials](https://protocol.dfos.com/credentials)). ### The `api:` resource and its actions The credential side of this capability is one resource family, registered in [CREDENTIALS, Resource Types](https://protocol.dfos.com/credentials#apihost-credential-gated-api-access) with its consuming rules here. **`` is the API's lowercase authority**: the bare hostname on the default HTTPS port, `host:port` on any other; never a scheme, never a path. Host-as-id means the resource names the surface by where it is served, so a fork or self-hosted deployment gets the same form for free, with no registry of deployments anywhere. It MUST byte-equal the proof's `host` (a non-default port appears in both or neither), so the resource id and the request binding name the same origin. **Two forms, one level deep.** `api:` names the whole API at that authority; `api:/spaces/` names one space there, `` the space's protocol DID with the prefix stripped. Coverage is equal-or-ancestor: the host form covers every space form at that host, a space form covers only itself, and nothing crosses hosts ([CREDENTIALS](https://protocol.dfos.com/credentials#apihost-credential-gated-api-access)). **Actions are enumerated registry tokens.** The registry defines seven, each at one level: | Action | Level | Grants | | ------------------ | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `read:profile` | account | Read access to the granting user's own profile: display name, handle, avatar, and public profile fields. The account email address is excluded; that is `read:email` | | `read:email` | account | Read access to the granting user's account email address | | `read:memberships` | account | Read access to the granting user's own memberships: the spaces and groups the account belongs to, and its roles in them | | `read:posts` | space | Read posts and their comments in covered spaces as the granting user: the user's own member projection, including content the user is entitled to by payment | | `write:upvotes` | space | Upvote, and remove upvotes from, posts and comments in covered spaces | | `write:comments` | space | Create, edit, and delete the granting user's own comments in covered spaces | | `write:posts` | space | Create, edit, and delete the granting user's own posts in covered spaces. Never announce, broadcast, pin, backdate, or moderate | **Level is where a token is demanded.** An account-level token is demanded against the host form only. A space-level token is demanded against the space form for the space a route addresses, and the host form satisfies that demand by ancestor coverage: it means every space the subject is in, spaces joined later included. An account-level token on a space entry is inert, never demanded there and not an error. A write token grants the subject's own content only; moderating another member's content is not in this registry. **Every token is a real grant, not a public-data ceremony.** A profile document may be non-public, so `read:profile` gates access an anonymous fetch does not have. A serving route applies the registry per token, never as a lump: the granting user's DID is already known from the credential chain itself (step 10), while each field class beyond it requires its own token. A `/v1/...` segment in a route path, as in the [payload example](#payload) above, is the serving API's own path versioning; the registry names actions, never paths. New tokens and new child forms register here additively, each a row. A grant carrying several tokens is an ordinary comma-separated action list (`read:profile,read:email`), and narrowing is dropping tokens. **There is no action wildcard, and `read:*` is a trap, not a shorthand.** Per the [action lattice](https://protocol.dfos.com/credentials), `*` is a **literal token**: an `att` entry with action `read:*` matches only a route requiring the literal action `read:*`, which no route ever will, so a `read:*` entry grants nothing at verification. This is a matching fact, not an attenuation fact: `{read:*}` is an ordinary non-empty action set, so under the subset rule it survives a delegation hop only when the parent's set also contains `read:*`. Growth is enumeration, always. **Attenuation follows coverage.** `api:` is the one non-`chain:` form with a registered hierarchy: a host entry narrows to a space entry, a space entry narrows only to itself, and coverage never crosses hosts or resource types. `api:*` is an ordinary id covering only itself, which is never a served host. #### Issuance How a third party obtains an `api:` credential is not this surface's concern; the proof binds to whatever valid credential is presented. The canonical issuance moment is a [credential-returning scope](#scopes-and-credentials): the user consents at the hosting platform's front door, and the credential comes back through the callback, with `iss` the user's DID, `aud` the third party's `client_did`, `att` entries on the platform's API host as the [coalescing rule](#scopes-and-credentials) decides, and `exp` at the issuer's discretion with revocation as the timely lever. Delegation onward is ordinary credential machinery: the third party MAY sub-delegate its grant through `prf` chains, and the verification walk holds every hop to monotonic attenuation. ### One envelope, optional credential The [identity proof](#the-identity-proof) answers "is the named DID making exactly this request, now?" The [request proof](#the-request-proof) answers "is the credential's audience making exactly this request, now, under this grant?" Same canonical-bytes machinery, same freshness bounds, same host binding, same key resolution at the head. The `credentialCID` member and the credential walk are the whole delta, and the `typ` gate keeps the two claims distinct on the wire. The [sign-in challenge proof](#sign-in) is a different genus: a challenge-response artifact a relying party verifies once to establish a relationship, never a per-request token. | Concern | Identity proof | Request proof | | ----------------- | ----------------------------------------------- | ---------------------------------------------------------------- | | Question answered | "Is this DID making exactly this request, now?" | "Is the credential's audience making exactly this request, now?" | | JWS `typ` | `did:dfos:identity-proof` | `did:dfos:request-proof` | | Payload | `method, host, path, bodyHash, iat`, `jti?` | the same, plus `credentialCID` | | Grants | Nothing, the resource's local policy decides | Whatever the presented credential's attenuation covers | | Failure tiers | 401 / 409 / 503 | 401 / 403 / 409 / 503 | | Stands alone | Yes, it is the whole AuthN statement | No, meaningless without its credential | Any DFOS-gated HTTP surface (the canonical API, a fork, a relay surface) consumes this family by asking, per route, which claim it needs: bare identity, then local policy, or an exercised grant. Growth is additive members on this family, since both artifacts ignore unknown members, never a new envelope. ### Advertising in OpenAPI A host that serves this family states, in its OpenAPI document, exactly which claim each route needs, so a generic client signs the right artifact for any host from the document alone. The convention below binds documents and the clients that consume them, never the wire: a host that publishes no document is exactly as gated as one that does. #### Scheme vocabulary Three security-scheme shapes, declared in `components.securitySchemes`. Component names are the host's choice (the names below are RECOMMENDED); consumers identify schemes **structurally**, by `type` / `scheme` / header `name` and the `x-dfos-typ` marker, never by component name. ```yaml components: securitySchemes: dfosIdentityProof: type: http scheme: dfos x-dfos-typ: did:dfos:identity-proof dfosRequestProof: type: http scheme: dfos x-dfos-typ: did:dfos:request-proof x-dfos-actions: read:profile: Read the granting user's own profile read:email: Read the granting user's account email address dfosCredential: type: apiKey in: header name: X-Credential ``` - **The two proof schemes are `type: http` with `scheme: dfos`**, literally accurate: the wire is `Authorization: DFOS `, and the scheme token is matched case-insensitively per [HTTP carriage](#http-carriage) (lowercase is RECOMMENDED, matching OpenAPI's registered-scheme convention). `x-dfos-typ` names the envelope the scheme carries, and under this convention it is REQUIRED on every `scheme: dfos` security scheme. A consumer encountering a document without it MAY fall back to the combination rules below, which are unambiguous for a proof scheme standing alone and for a proof scheme ANDed with the credential scheme; the mixed authn/authz combination is expressible only with explicit markers. - **The credential scheme is `type: apiKey, in: header, name: X-Credential`**, the grant header, named case-insensitively as HTTP field names are. `apiKey` is OpenAPI's honest generic type for "a token in a named header", and nothing declared this way is a bearer artifact. - **`x-dfos-actions` on the request-proof scheme is the host's action catalog**: a map of action token to human-readable description, restating the registry the host serves. Tokens are opaque strings to consumers. The member name is shared with the [operation-level extension below](#required-actions), and the two shapes are **strict and deliberately different**: on a scheme the member MUST be a map, on an operation it MUST be an array. A scheme member that is not a map, like an operation member that is not an array, is malformed, and consumers reject it rather than guess. #### Requirement combinations OpenAPI's own security semantics carry the claim structure: within one security-requirement object the named schemes are ANDed, and across the `security` array requirement objects are alternatives, so a client satisfies any one. Under this convention an operation's requirements mean: | Combination | Claim the route needs | | ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | No requirement, or empty `security: []` | Anonymous, no artifact of this family | | Identity-proof scheme alone | An [identity proof](#the-identity-proof): authentication only; the resource's local policy decides the rest | | Request-proof scheme AND credential scheme | A [request proof](#the-request-proof) with the credential it binds, the delegated-grant profile. Two schemes because the wire is two headers; a request-proof scheme alone advertises a route no conforming client can call | | Identity-proof scheme AND credential scheme | An identity proof plus a credential in the route's own declared role, the authn/authz split the [carriage rule](#the-identity-proof) names, as on the relay content plane's non-creator blob reads | | The empty requirement object `{}` beside others | Optional authentication: the route serves an anonymous projection to a request carrying no artifact, and the subject's projection under one of its other alternatives | The combination **is** the profile declaration. A client selects the alternative it can satisfy and signs the artifact that combination names; a host offering a route under either claim lists two requirement objects. **Optional authentication.** A route MAY list the empty requirement object beside its proof alternatives (`security: [{}, {dfosIdentityProof: []}, {dfosRequestProof: [], dfosCredential: []}]`). Such a route serves an anonymous projection to a request carrying no artifact, and the subject's projection to a valid proof whose grant covers the route. A valid proof whose leaf does not cover the required resource or action serves exactly the anonymous projection: a credential only ever adds. Coverage is a ceiling, not a standing: the projection served is the subject's live standing, so a covered space the subject is not in serves the anonymous projection too. A present but invalid proof is refused by the verdict mapping, never degraded to anonymous, because an app that learns to ignore authentication failures has no authentication. #### Required actions OpenAPI permits a non-empty scope list only on `oauth2` and `openIdConnect` schemes, so required actions ride an operation-level extension rather than the requirement object. An operation under the delegated combination declares the actions its route requires as `x-dfos-actions` on the Operation Object: an array of **alternatives**, each alternative a single action token or an array of tokens, and the presented leaf credential satisfies the operation when its attenuation covers **every token of any one alternative**. The array form is the only form: a single required action is a one-element array, never a bare string, and a document carrying any other shape here is malformed. ```yaml # read:profile OR read:email — the route serves the field classes the grant covers x-dfos-actions: [read:profile, read:email] # a route requiring two tokens at once is one AND-alternative x-dfos-actions: [[read:profile, read:email]] ``` An operation under the delegated combination carrying **no** `x-dfos-actions` requires a valid `api:` credential and no particular action token: some `att` entry naming the host's resource, with the action check vacuous. Declaring that class by absence is unambiguous because the requirement combination, never the action list, distinguishes the delegated profile from the identity proof. `x-dfos-actions` never appears on an identity-proof-only operation. #### The document is discovery, never authority A consuming client reads the document to decide what to **attempt**, and the serving verifier's own configuration decides what is **accepted**. The [verification algorithm](#verification-algorithm) and its verdict mapping are the normative machine signal, and a stale or wrong document changes what a client tries, never what a host serves. A client MUST honor the runtime verdicts over the document, and an explicit client override, forcing a profile or going anonymous, is always legitimate. Declaring this family as `oauth2` to put action tokens in the scopes slot would lie to every generic consumer: there is no authorization server, no flow object, and nothing bearer-shaped. The schemes stay the honest `http` / `dfos` and `apiKey` shapes generic tooling renders correctly, and DFOS-specific structure rides `x-` extensions, which conforming OpenAPI tooling preserves and ignores. A host SHOULD serve its OpenAPI document at a stable URL. The canonical deployment serves its own at `https://api.dfos.com/openapi.json`, and a [relay](https://protocol.dfos.com/relay) advertises its document's URL in its well-known response. There is no registry of deployments. ### API security notes - **A stolen credential is a metadata leak, not an access leak.** The credential names the grant: who authorized whom, over what, until when. Without the audience key, a captured credential authorizes nothing anywhere. The artifact that must never leak is the audience key, which never crosses a channel. - **Within-window replay of an identical request is the accepted bound on a read-shaped route.** A captured proof replays only as the byte-identical request, inside the freshness window, against the same host, and a re-read returns the same response. **A write-shaped route closes it with `jti`.** A deployment gating writes MUST require the registered [`jti` member](#payload) on every write-shaped route and record it by an **atomic insert-if-absent** into a replay cache keyed on the presenter DID and `jti`, whose entry lives until the proof's own expiry (`iat + W + S`). The verifier never held the client-chosen value beforehand, so the primitive is insert-if-absent (accept iff newly inserted), not the check-and-delete a server-minted nonce would use. An already-seen `jti` is the **`replayed`** verdict, **409**, distinct from an invalid proof so that a client whose request timed out re-reads state rather than retrying blind. A read-shaped route MAY ignore the member. - **`aud: "*"` is refused at every level because it un-asks the question.** A public credential has no audience, so there is no audience key, so there is nothing whose possession a proof could prove. A public credential anywhere in the chain, not just at the leaf, reopens the hole, because a public parent satisfies audience-linkage for any child issuer. The refusal scans the whole presented chain. - **Revocation is the user's lever, and the verify path is where it has teeth.** `exp` is signer-discretionary and may be months out; what keeps a standing grant answerable is that step 8 re-checks revocation on every request against the verifier's current knowledge. That knowledge is as fresh as the verifier's revocation source: an API host SHOULD resolve revocations from the relays its users' identities actually list, and a deployment choosing a cache interval is choosing its revocation latency. - **A deployment MAY refuse credentials it did not issue.** The algorithm defines validity, not acceptance. A deployment that keeps a registry of the credentials it issued MAY refuse an otherwise-valid presented credential absent from that registry, as a credential-layer refusal (403). This is acceptance policy layered above the algorithm, not a verification step. - **The browser is not a keyholder.** A browser application cannot hold the audience key non-extractably, so the supported browser shape is a backend-for-frontend: the browser holds an ordinary session with the third party's own backend, and the backend holds the key and signs proofs. That backend seam is a signing surface, and it MUST authorize the request it is about to sign against its own session, signing the coordinates the session is entitled to rather than whatever `{method, path, body}` a browser client hands it. A backend that signs blindly is a confused-deputy oracle. - **What the proof binds, and what it does not.** The proof covers `{method, host, path, bodyHash}` and nothing else about the request. Request **headers** are unbound, and three matter, each of which a proof-authenticated route MUST close: method-override headers (`X-HTTP-Method-Override` and kin) MUST NOT be honored; `Content-Encoding` is settled before hashing by construction; and `Content-Type` decides how identical octets parse, so a body-bearing route MUST pin exactly one media type, rejecting others before parsing, or bind the normalized `Content-Type` as an additive member. - **Protected media into a browser context is an exchange, never URL-carriage.** A browser needs `` and `