diff --git a/docs/site/src/content/docs/explanation/disclosure-and-minimization.mdx b/docs/site/src/content/docs/explanation/disclosure-and-minimization.mdx new file mode 100644 index 00000000..5b9b4dca --- /dev/null +++ b/docs/site/src/content/docs/explanation/disclosure-and-minimization.mdx @@ -0,0 +1,252 @@ +--- +title: Disclosure and minimization +description: How Registry Notary bounds what a claim answer reveals through three disclosure modes, policy-bound mode selection, and a minimization discipline the claim author models. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-06-28" +doc_type: explanation +locale: en +standards_referenced: + - sd-jwt-vc + - oid4vci + - w3c-did + - json-ld + - cccev +--- + +The overview establishes the boundary: Registry Relay returns scoped records, Registry Notary +returns a minimized answer, and a claim carries one of three disclosure modes. This page goes +underneath that summary. It explains the disclosure model as a mechanism (what each mode +computes, how a requested mode is bounded, what a downgrade does) and the minimization +discipline as a modelling stance rather than an enforced invariant. The distinction matters: +the strength of any single answer is a property of how its claim was modelled, not a guarantee +the stack imposes on every answer. + +## Disclosure controls a result, minimization shapes a claim + +Two separate ideas are often conflated. Disclosure is a runtime control: given an evaluated +value, a disclosure mode decides what the result body carries. Minimization is a design-time +discipline: a claim author decides what a claim evaluates in the first place, and therefore the +most a disclosure mode could ever reveal. + +Minimization is modelled rather than automatic. The spec states the discipline in prose: a +claim definition describes one decision or one extracted value, and a claim that tries to +return a whole record over-collects and is hard to authorize (`rs-dm-claim.mdx`). That sentence +is guidance, not a config-load invariant. `REQ-DM-CLAIM-003` makes reading only the fields a +rule needs a `SHOULD`; its single hard `MUST` is that a binding declaring an input allow-list +(`allowed_target_inputs` or `allowed_requester_inputs`) rejects any request supplying a path +outside that list, so a binding cannot over-collect by accident. Least disclosure is likewise a +per-claim `SHOULD`: `REQ-DM-CLAIM-008` says a privacy-sensitive claim should default to the +least-revealing useful mode, while the enforced behavior is narrower (apply the claim's +`default` when the caller requests none, refuse a mode outside `allowed`). The stack does not +force a narrow read or a least-revealing default; it enforces the policy a claim declares. + +A claim does declare exactly one rule. `REQ-DM-CLAIM-006` requires one rule of one implemented +kind: `exists` (the presence of exactly one source record), `extract` (a value read from a +source field), or `cel` (a value derived from source fields or earlier claim results through a +hardened expression). The rule is the single decision at the center of a claim. That single-rule +shape is what gives a claim a chance to be minimal; whether it is minimal depends on what the +rule evaluates and which mode discloses it. + +## The three modes in depth + +Registry Notary defines exactly three disclosure modes, `value`, `predicate`, and `redacted` +(Section 5 of `rs-pr-notary.mdx`; the `DisclosureProfile` enum in `registry-notary-core/src/model.rs`). Each +mode is a projection over the evaluated value, and each carries a distinct privacy contract. + +### `value`: the full value, minus policy-marked fields + +The `value` mode returns the evaluated value whole. In the runtime, with no field redaction in +play, the result is the evaluated value cloned in full (`runtime.rs`), not a coerced scalar. A +claim's declared value type may be an object or an array, and the runtime accepts those types, +so a broadly modelled `extract`, `cel`, or object claim returns row-shaped, record-like data +under `value`. This is precisely the over-collection risk the modelling discipline warns about: +`value` mode is not a safeguard against record-shaped disclosure, it permits it when the claim +is modelled that way. + +Object-field redaction narrows `value` mode, but only under tight preconditions. It applies +when the claim value type is `object`, the redaction set is non-empty, and every entry is a +non-empty top-level field name with no dots or brackets and not the literal `value` +(`runtime.rs`). When those hold, the matching policy's named fields are removed from the +returned object and the rest of the object is returned. If a field marked for redaction is +absent from the object, the result is refused (`DisclosureNotAllowed`) rather than silently +returned. Field redaction is therefore a per-field subtraction inside an object value, not a +general mechanism for trimming arbitrary structure. + +Row-level redaction on a non-object value behaves differently. When redaction fields are present +but the value does not qualify for object-field redaction, a `value` request is forced to +`redacted` before the allowed-set check runs (`runtime.rs`). The whole result collapses; there +is no partial reveal of a non-object value. + +### `predicate`: a boolean satisfaction, and only when the value is boolean + +The `predicate` mode conveys only the rule's true/false satisfaction (`rs-pr-notary.mdx` +Section 5). It is derived by interpreting the evaluated value as a boolean: the runtime projects +`result.value.as_bool()` (`runtime.rs`), which yields a value only for a JSON boolean. A +non-boolean evaluated value therefore yields an absent value under `predicate`, and the +satisfaction outcome is likewise absent. `predicate` does not threshold, coerce, or summarize a +non-boolean value; it is meaningful only for a claim whose rule yields a boolean. A claim that +needs to answer yes/no without exposing a figure must model the decision so the rule itself +produces the boolean (for example a `cel` comparison), rather than expecting `predicate` to +reduce a numeric value for it. + +`predicate` is also incompatible with a redacted-field result. When redaction fields are present +and the effective mode is `predicate`, the request is refused (`DisclosureNotAllowed`, +`runtime.rs`). Field-level redaction and predicate disclosure do not combine. + +### `redacted`: neither value nor outcome + +The `redacted` mode withholds both. `REQ-PR-NOTARY-010` requires that a redacted result not +carry the underlying source value and not convey the satisfaction outcome: the body withholds +both the value and the predicate. The runtime matches this: the disclosed value is absent, and +the `satisfied` outcome is populated only for `value` and `predicate` (by reading the value as a +boolean) and is absent for `redacted` (`runtime.rs`). In the claim-result JSON shape a redacted answer +carries no value and no yes/no. The CCCEV JSON-LD render is the exception: it serializes +`cccev:isConformantTo` from `satisfied` with a `false` fallback, so a redacted CCCEV node still +carries a boolean outcome field (`runtime.rs`). + +## How policy binds a mode + +A claim's disclosure policy is three settings in the claim definition: a `default` mode, an +`allowed` set of modes, and a `downgrade` policy (`DisclosureConfig` in +`registry-notary-core/src/config.rs`). The defaults are the most restrictive available: the +`default` mode is `redacted`, and the sole default `allowed` entry is `redacted` +(`config.rs`). A claim that says nothing about disclosure reveals nothing. + +A caller may request a mode (the evaluation request carries an optional `disclosure` field), but +the request is bounded. When the caller requests none, the claim's `default` applies; in the +multi-claim case the runtime falls back to the `default` of the first requested claim +(`runtime.rs`). `REQ-PR-NOTARY-009` fixes the rest: Notary refuses a mode not in the claim's +`allowed` set, and applies the `default` when the caller requests none. + +The `downgrade` policy decides what happens when a requested mode is outside `allowed`. It has +exactly three variants, `deny`, `default`, and `redacted` (`DisclosureDowngrade` in +`model.rs`), and it defaults to `deny` (`config.rs`). The runtime resolves them so: + +| `downgrade` | When the requested mode is not allowed | +|------|------| +| `deny` (default) | refuse the request (`DisclosureNotAllowed`) | +| `default` | substitute the claim's `default` mode | +| `redacted` | substitute the `redacted` mode | + +A downgrade does not bypass the allowed-set rule. When the substituted fallback (`default` or +`redacted`) is itself not in the claim's `allowed` set, the request is still refused +(`runtime.rs`). The fallback is a choice among permitted modes, not an escape hatch around them. + +One consequence is worth stating directly. Every +evaluation result records the disclosure mode that was actually applied (`REQ-PR-NOTARY-008`), +and that recorded mode is the effective mode after any forcing or downgrade, not the mode the +caller requested. The view emits the effective mode (`runtime.rs`). A request that asked for +`value` and was forced to `redacted` records `redacted`. The recorded mode is the truth of what +was disclosed, not the intent of the asker. + +## Selective disclosure in the credential + +Disclosure modes govern an evaluation result. An issued credential is a distinct layer with its +own disclosure mechanism, and the two should not be conflated. The credential is an SD-JWT VC +(`typ` header `dc+sd-jwt`, signed with the profile's configured algorithm, EdDSA over Ed25519 by +default or ES256 over P-256, `REQ-PR-NOTARY-013`) that gives the +holder selective disclosure: the holder chooses which fields to reveal to which verifier. The +signed body carries a SHA-256 digest of each selectively disclosable field rather than the field +value, so an unselected field stays hidden and a holder cannot present a disclosure that was not +in the original credential (`REQ-PR-NOTARY-014`). + +The granularity of that selection is the claim output, not a field inside an output's value. The +issuer builds one Disclosure per output. In projection mode there is one Disclosure per +configured projection output, named by its `output_name`, whose value is the claim result's +value taken whole (`sd_jwt.rs`); a projection entry maps one `claim_id` to one `output_name`. In +the default, non-projection mode there is one Disclosure per claim result, named by `claim_id`, +whose value is the entire claim-result object (`sd_jwt.rs`). Either way an object-valued output +is emitted as a single Disclosure value, not split field by field. A holder who reveals an +object-valued output reveals all of it; the unit of selective disclosure is the output. + +Projection is also conditional. A projected claim whose result is unsatisfied or whose value is +null or absent is rejected (`CredentialIssuanceFailed`, `sd_jwt.rs`), so projection does not +unconditionally disclose every configured output. + +Holder binding sits alongside selective disclosure and is profile-conditional, not universal. +The default `holder_binding` mode is the literal string `none`, which issues an unbound +credential (`HolderBindingConfig` in `config.rs`). The self-attestation and OID4VCI path forces +binding: profile validation rejects any profile whose `holder_binding.mode` is not `did`, whose +`proof_of_possession` is not `required`, or whose `allowed_did_methods` is not exactly `did:jwk` +(`config.rs`). When a credential is holder-bound, `REQ-PR-NOTARY-015` names the holder's public +key as a `did:jwk` in the `cnf` claim (`did:jwk` is the only supported binding method) and +requires a fresh, audience-bound holder proof at presentation; the wallet credential endpoint +(`POST /oid4vci/credential`) must require a valid access token and a proof of possession, and +must not issue without one (`REQ-PR-NOTARY-017`). Binding is a property of the issuance path, not +of the three evaluation-disclosure modes. + +## Disclosing non-existence + +A subtle disclosure question is what a caller learns when an answer cannot be produced. A +matching failure can leak existence: a granular "subject not found" tells the caller the subject +is absent, and a granular "ambiguous" tells the caller more than one record matched. By default +that leak is closed. `REQ-DM-CLAIM-005` requires a matching failure to collapse to a single +public reason (`evidence.not_available`), with the granular reason retained only in the audit +record, so the matching surface cannot be used as an existence oracle. A deployment may disable +the collapse, but only in a controlled environment where exposing not-found, ambiguous, and +rejected outcomes to the caller is acceptable. + +In code, collapse defaults to enabled (`default_collapse_matching_errors` returns `true`, +`config.rs`) and is a per-source-binding setting, a field of the binding's matching config, not +a single deployment-wide switch (`runtime.rs` reads `binding.matching.collapse_matching_errors` +per binding). When collapse is enabled, a broad set of matching errors (source, requester, and +relationship not-found; ambiguous; insufficient-attributes; policy-rejected; and others) maps to +`MatchingEvidenceNotAvailable`, which surfaces the public code `evidence.not_available` while +retaining the granular reason as an `audit_code` (`runtime.rs`). When the binding's flag is +false, the raw error is returned unchanged. A separate helper handles the dependent-lookup +resolution stage and additionally folds `InvalidRequest` (for example a non-scalar prior source +field) into `evidence.not_available` with its specific `audit_code`, because that condition is a +function of upstream row data the caller is not authorized to observe (`runtime.rs`). + +## Disclosing across a federation boundary + +When one Registry Notary delegates an evaluation to a configured peer, what crosses is a scoped, +signed evaluation result, never a credential. A peer posts a compact signed JWT request to +`POST /federation/v1/evaluations` and receives a compact signed JWT response carrying a scoped +evaluation result; before any source read, the serving Notary verifies peer policy, replay +state, purpose, profile, and audience (`REQ-PR-NOTARY-018`). Federation is static-peer only: +peers are loaded from configuration at startup, a request from an unconfigured peer is rejected, +and dynamic discovery, cross-peer shared replay storage, and federated credential issuance are out +of scope (`REQ-PR-NOTARY-019`). The federated answer is the same kind of minimized, mode-governed +evaluation result, signed for transport, rather than a portable credential. + +## Honest limits + +The disclosure model is a set of policy-enforced controls, not a cryptographic privacy proof. +The limits below are the documented honest framing, not discovered defects. + +- **This is not zero-knowledge.** A `predicate` answer is a policy-enforced boolean computed + inside the service, and SD-JWT selective disclosure is digest omission (the body carries a + SHA-256 digest of each disclosable field, `sd_jwt.rs`). Neither is a zero-knowledge proof, and + the documentation should not imply one (`records-stay-home.mdx`). +- **Minimization is modelled, not automatic.** A claim reveals only what its author configured. + `value` mode discloses the evaluated value less any object fields the policy redacts and is not + constrained to a scalar, so a claim modelled to return an object or extracted record returns + one. The single-rule shape and the least-revealing-default guidance are a modelling stance the + author adopts, not a property imposed on every answer. +- **A plain result is provenance-tagged, not signed.** The everyday evaluation response carries + provenance metadata using the `registry-notary-claim-provenance/v1` shape (`REQ-PR-NOTARY-028`; + the `ClaimProvenance` struct in `model.rs` holds `schema_version`, `generated_by`, `used`, and + `derived_from`, with no signature field). Provenance is correlation context, not a signature + over the result. Cryptographic verifiability comes from the SD-JWT VC credential (signed with + its configured EdDSA or ES256 key, `REQ-PR-NOTARY-013`) or the signed federation result (a compact signed JWT, + `REQ-PR-NOTARY-018`), not from the default evaluation response. +- **Correctness depends on the configured source.** Notary reports what its configured source + says and does not independently vouch for whether the source is correct or current + (`REQ-PR-NOTARY-005`). +- **Alignment with a standard is not conformance.** Conformance to `RS-PR-NOTARY` does not imply + conformance to any external standard cited in its `standards_referenced` (`rs-pr-notary.mdx`), + and a CCCEV-shaped JSON-LD output is a profiled subset that must not assert conformance to CCCEV + 2.00 (`REQ-PR-NOTARY-012`). In the standards register, `aligns_with` means the project follows a + standard's model and intent without claiming formal conformance (`standards.mdx`). + +## Related + +- The protocol and data-model contracts: [RS-PR-NOTARY](../../spec/rs-pr-notary/), + [RS-DM-CLAIM](../../spec/rs-dm-claim/), [RS-SEC-G](../../spec/rs-sec-g/) +- The overview: [Records stay home](../records-stay-home/) *(explanation)* +- Evidence issuance, end to end: [Evidence issuance](../evidence-issuance/) *(explanation)* +- The security posture: [Trust and security](../trust-and-security/) *(explanation)* diff --git a/docs/site/src/content/docs/explanation/records-stay-home.mdx b/docs/site/src/content/docs/explanation/records-stay-home.mdx new file mode 100644 index 00000000..a1c26530 --- /dev/null +++ b/docs/site/src/content/docs/explanation/records-stay-home.mdx @@ -0,0 +1,225 @@ +--- +title: Records stay home +description: How an institution proves facts from registries it already holds, without the records leaving. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-06-28" +doc_type: explanation +locale: en +standards_referenced: + - sd-jwt-vc + - oid4vci +--- + +An institution that runs a civil registry, a social-protection database, or a health +registry already holds the records it needs. Registry Stack lets it **answer questions +about those records** (*is this person alive? is this household eligible?*) and return +a result another system can trust, while the records themselves are **read where they +already live, never written back, and never returned as the answer**. + +This page explains what that means in practice: what stays inside the institution's +boundary, what crosses it, and, equally important, what the design does and does not +guarantee. + +## A question goes in, an answer comes out + +The mental model is one sentence: **a scoped question crosses into the institution, the +record is read in place, and only a computed answer crosses back out.** + +A caller never sends the value it is asking about and never receives the underlying +record. It sends the id of a *claim* (a single, pre-modelled question) and the inputs that +claim needs to resolve the subject: a subject identifier, plus, where the claim's matching +policy requires them, target or requester attributes, further identifiers, or relationship +attributes. It receives one of a few narrow shapes of answer: a yes/no, a single value, a +machine-readable evaluation result, or a credential the subject can carry in a wallet. The source row that the +answer was computed from stays behind. + +## The boundary + +```mermaid +flowchart LR + subgraph inst["Institution: data stays here"] + src[("Source registry\nCSV · XLSX · Parquet · PostgreSQL")] + relay["Registry Relay\nprotected read API · scoped consultation"] + notary["Registry Notary\nevaluate · disclose · issue"] + key>"Signing key\n(private half never leaves)"] + audit[("Audit log")] + src -- read in place --> relay + relay -- governed read --> notary + notary -. records .-> audit + relay -. records .-> audit + key -. signs .-> notary + end + caller["Caller / verifier"] + holder(["Subject / wallet"]) + caller == "request: claim id + subject inputs + scope" ==> notary + notary == "answer: yes/no · value · evaluation result" ==> caller + notary == "holder-bound credential" ==> holder + caller == "scoped record read · per-dataset row scope" ==> relay + relay == "authorized source records" ==> caller + + classDef inside fill:#eef,stroke:#334,stroke-width:1px; + classDef outside fill:#f7f7f7,stroke:#777,stroke-dasharray:3 3; + class src,relay,notary,key,audit inside; + class caller,holder outside; +``` + +*Two surfaces cross the boundary, both governed.* Registry Notary evaluates one modelled +question against a source and returns a shaped, minimized answer; it is the only component +that evaluates claims, applies disclosure policy, and issues credentials. Registry Relay +turns an existing file or database table into a read-only, access-controlled API without +replacing the source, and publishes restricted, scoped consultation routes that return +source records to authorized callers holding the dataset's `:rows` permission. Notary is the +strongest minimization; Relay record reads are scoped and audited, not open data. Either +way the source is read in place, never written back, and never aggregated centrally. + +## What stays home + +- **Source data is read in place.** Relay reads sources as batch snapshots or table scans; + there is no write-back to the source registry, and runtime services expose no + data-mutation routes. The source keeps running as it always has. +- **Storage internals stay private.** The paths, table names, and backend credentials that + point at the source live in the service's runtime configuration, decided at startup. They + are never part of the public API surface, and never part of a portable metadata file that + gets distributed. +- **The institution keeps custody.** The design premise is *distributed custody*: each + authority retains control of its own registry data, and the stack does not aggregate + records into a central system. It provides the exchange surface, not a data lake. +- **Private signing keys never leave the issuer.** The institution publishes the *public* + half of its signing key so anyone can verify a signed credential or signed result; the + private half stays inside. + +## What crosses the boundary + +What crosses depends on the surface. Registry Relay returns scoped records to an +authorized caller: a governed, audited, paginated read bounded by the caller's per-dataset row +scope (`:rows`) and the dataset's configured filters and limits. Registry Notary returns the answer a +rule computes rather than the source row; +keeping that answer narrow is a modelling discipline, since a well-modelled claim returns +one decision or one extracted value. A Notary answer takes one of a few shapes: + +- **A yes/no**: only the true/false satisfaction of the modelled rule. +- **A single value**: the evaluated value itself, when the claim's disclosure mode is + `value`. +- **A machine-readable evaluation result**: a claim-result document carrying *provenance + metadata*: which evaluation produced it, under which policy, across how many sources. This + provenance lets a receiving system trace the result; it is not a cryptographic signature. +- **A holder-bound credential**: an SD-JWT VC the subject can store in a wallet and present + later. Unlike the plain result, the credential is cryptographically verifiable against the + issuer's published keys. + +Across a federation boundary (one institution's Notary asking another's) what crosses is +a scoped, signed evaluation result, never a credential. + +## How much an answer reveals: the three disclosure modes + +Every claim carries a **disclosure mode** that fixes how much of the answer the caller +receives. There are exactly three: + +| Mode | Discloses | Withholds | +|------|-----------|-----------| +| `value` | the evaluated value, less any object fields the policy redacts | nothing beyond policy-redacted object fields | +| `predicate` | only the true/false satisfaction, for a claim whose rule yields a boolean | the underlying value | +| `redacted` | neither: the result carries no value **and** no yes/no | the value *and* the outcome | + +The mode is policy-bound: a caller may request a mode, but a claim defines an `allowed` +set, a `default`, and a `downgrade` policy. Under the default `deny` downgrade the service +refuses a requested mode outside the allowed set; a `default` or `redacted` downgrade +instead substitutes that fallback mode when the fallback is itself allowed. The default mode +applies when the caller requests none, and every result records which mode was applied. A privacy-sensitive claim is expected to default to the +least-revealing mode that still answers the question. + +This is the mechanism behind "prove a fact without sharing the record". To check whether a +person has a registered record, model the question as an *existence* rule and disclose it +as a `predicate`: a resolved record returns `true`, and the row never crosses the +boundary. By default a record that does not resolve collapses to a single not-available +reason rather than a `false`, so the lookup cannot be used as an existence oracle. To check eligibility without exposing an income figure, derive the decision with +an expression rule and disclose the eligibility boolean as a `predicate`; the income value +stays home. + +## Why the answer is not the record + +A credential is not a copy of the record. It is an **SD-JWT VC**: the signed body carries a +SHA-256 *digest* of each selectively disclosable field rather than the field value, so a +field the holder does not present stays hidden. Holder binding is set by the credential profile. A holder-bound profile ties the credential +to the holder's key so it is not presentable without the matching private key; the default +`none` mode issues an unbound credential. Either way, the holder chooses which fields to +reveal to which verifier. Each selectively disclosable field is a whole claim output, so an +object-valued output is revealed as a unit, not field by field within the object. Anyone can +verify it against the issuer's +published public keys, served without authentication so a verifier needs no credential of +its own. The issued credential carries no full record payload. + +## How the boundary is enforced + +The "stays home" property rests on a few enforced rules, covered in depth in the Trust & +Security material: + +- **Scope-before-source, deny-by-default.** A service checks the caller's scope *before* it + reads any source or evaluates any claim, and does not widen a caller's reach at request + time beyond what its configuration grants. Anything that touches a record or a claim + requires authentication. The routes reachable without it return no record or claim result on + their own: liveness and readiness probes, the public verification keys, the public metadata + routes (API, issuer, and credential type), and, where OID4VCI issuance or credential status + is enabled, the issuance-flow and status routes that protocol defines, which run their own + flow checks. +- **A permit, or a closed door.** On a governed read, the policy decision point must return + a permit before data is returned; a denial fails closed with a stable reason rather than + falling back to an ungoverned read. +- **Every person-level request is audited.** An audit record captures at least the caller, + a request id, the scope or claim the request exercised, and the declared purpose where + one was supplied. A + deployment can run audit fail-closed, so a request whose audit record cannot be written + does not return success. + +## What this guarantees, and what it does not + +"Records stay home" is a precise, narrow promise. Reading it as more than it is would be a +mistake, so the limits are stated plainly here. + +- **It is not "data never moves" and not "air-gapped".** The promise is *read-in-place, no + write-back, retained custody*. Authorized, minimized answers do leave the boundary by + design: that is the point of the system. +- **Minimization is modelled, not automatic.** `value` mode discloses the evaluated value, + less any object fields the matching policy marks for redaction; it is not constrained to a + scalar, so a claim modelled to return an object or extracted record returns one. A claim + reveals only what its author configured it to reveal; least disclosure is a design choice + the claim makes, not a property the stack imposes on every answer. +- **Correctness depends on the source.** Notary reports what the configured source says; it + does not independently vouch for whether the source is correct or current. +- **A plain result is provenance-tagged, not signed.** The everyday evaluation response + carries provenance metadata, not a cryptographic signature. Cryptographic verifiability + comes from the SD-JWT VC credential and the signed federation result. A receiving system + that must verify an answer cryptographically uses the credential, not the default response. +- **Matching is only as strict as it is configured.** Notary resolves a subject through its + configured matching policy and does not independently verify identity beyond that. By + default a matching failure collapses to a single public reason, so the matching surface + cannot be used as an existence oracle. +- **This is not zero-knowledge.** A `predicate` answer is a policy-enforced boolean computed + inside the service; SD-JWT selective disclosure is digest omission. Neither is a + zero-knowledge proof, and the documentation should not imply one. +- **No revocation or erasure flow is specified; revocation is an optional operator surface.** + The `RS-*` specifications define no credential revocation, credential status, or + data-subject erasure. The implementation ships an optional credential-status surface (a status + list with states `valid`, `suspended`, `revoked`, and `expired`), + disabled by default and enabled per deployment; with it enabled, an admin-scoped route can + move a credential to `revoked`. Data-subject erasure is absent from both the specifications + and the implementation. A key rotated out may remain published so existing results stay + verifiable; that is not a revocation mechanism. +- **Several guarantees are the operator's to provide.** Network egress limits, key custody, + tenant isolation, audit retention, and transport security are supplied by the deployment, + not guaranteed by the stack. +- **The model is specified in draft.** The behaviour above is defined in the `RS-*` + specifications, which are still drafts and may change. Alignment with an external standard + is not a claim of conformance to it or of legal compliance. + +## Related + +- The security model and protocol contracts: [RS-SEC-G](../../spec/rs-sec-g/), + [RS-PR-RELAY](../../spec/rs-pr-relay/), [RS-PR-NOTARY](../../spec/rs-pr-notary/), + [RS-DM-CLAIM](../../spec/rs-dm-claim/) +- Evidence issuance, end to end *(explanation)* +- [Disclosure and minimization](../disclosure-and-minimization/), in depth *(explanation)* +- The security posture: [Trust and security](../trust-and-security/) *(explanation)* diff --git a/docs/site/src/content/docs/explanation/trust-and-security.mdx b/docs/site/src/content/docs/explanation/trust-and-security.mdx new file mode 100644 index 00000000..0ecf0609 --- /dev/null +++ b/docs/site/src/content/docs/explanation/trust-and-security.mdx @@ -0,0 +1,240 @@ +--- +title: Trust and security +description: A single map of the Registry Stack trust posture, the stack-versus-operator responsibility split, and the honest gaps a reviewer should weigh. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-06-28" +doc_type: explanation +locale: en +standards_referenced: + - sd-jwt-vc + - oid4vci + - w3c-did +--- + +This page is the trust map for a security reviewer or operator: one place to read the posture of +the runtime services and the line where their guarantees stop and the deployment's begin. It does +not repeat the disclosure model or the boundary narrative in full. For the boundary overview see +[Records stay home](../records-stay-home/); for the disclosure model in depth see +[Disclosure and minimization](../disclosure-and-minimization/). The normative source is the draft +`RS-*` specification set, principally [RS-SEC-G](../../spec/rs-sec-g/), +[RS-PR-RELAY](../../spec/rs-pr-relay/), and [RS-PR-NOTARY](../../spec/rs-pr-notary/). + +## The trust model in one frame + +The stack draws an explicit boundary between what the runtime services guarantee and what the +deployment supplies. `RS-SEC-G` Section 9 states it plainly: the security model ends where the +deployment begins. Inside the boundary the runtime services provide cryptographic and policy +primitives. Outside it the operator provisions, configures, and operates them. + +Three properties stay inside the boundary by design. Custody is distributed: each authority keeps +control of its own registry data, and the stack does not aggregate records into a central store +(`records-stay-home.mdx`). Consultation is read-in-place with no write-back: in v1 the runtime +services must not expose source-registry data mutation routes, and consultation APIs are read-only +(`REQ-ARC-G-003`). The private signing key never leaves the issuer: an issuer signs with an +asymmetric key and publishes only the public half (`REQ-SEC-G-007`). + +Several guarantees sit outside the boundary and are the operator's to provide: +secret and key provisioning, key custody and rotation schedule, audit retention and storage, +tenant isolation, transport termination and certificate management, network rate limiting at the +edge, deployment configuration, and incident response (`RS-SEC-G` Section 9). The stack provides +the primitives; the operator provisions, configures, and operates them. + +The responsibilities below also split along a service line within the stack. Registry Notary owns +claim evaluation, disclosure policy, and claim-credential issuance; Registry Relay must not perform +claim evaluation or issue claim credentials (`REQ-ARC-G-007`). That boundary does not prohibit Relay +from enforcing runtime access, freshness, and redaction policy on its own governed consultation +routes. It also does not cover signed response credentials: with `provenance` enabled, Relay attaches +its own VCDM 2.0 VC-JWT to an entity-record or aggregate response and, in gateway issuer mode, +publishes a `did:web` document, a second issuer and key surface distinct from the Notary claim +SD-JWT VC (`REQ-PR-RELAY-013`, `REQ-PR-RELAY-014`, `REQ-SEC-G-007`). + +## Posture table + +The table maps the key properties to their current grounded posture and to who provides each. The +"Provided by" column distinguishes a stack primitive, an operator responsibility, and a property +that is configured (a stack primitive whose effect depends on deployment configuration). + +| Property | Current posture | Provided by | +|------|------|------| +| Authentication | A runtime service runs exactly one authentication mode (static credentials/API key or OIDC) and authenticates every route returning person-level records or claim results before producing a response (`REQ-SEC-G-002`, `REQ-PR-RELAY-005`). | Stack (primitive); mode configured | +| Authorization / scope | Scope-based and deny-by-default, with scope-before-source: the required scope is enforced before any source read or claim evaluation, so a caller lacking the scope is refused before source access (`REQ-SEC-G-005`, `REQ-PR-RELAY-006`). | Stack (primitive); scopes configured | +| Audit | An audit envelope is a hash-chained append record; a deployment can run audit fail-closed, so a request whose audit record cannot be written does not return a successful result (`REQ-SEC-G-009`). The Notary record body omits one spec-required field (scopes); see Known gaps. | Stack (primitive); fail-closed configured | +| Disclosure and minimization | A claim answer carries one of three policy-bound modes; minimization is modelled by the claim author, not imposed on every answer. See [Disclosure and minimization](../disclosure-and-minimization/). | Stack (primitive); claim-modelled | +| Credential signing | An issued credential is an SD-JWT VC (`typ` `dc+sd-jwt`) signed with the profile's configured algorithm (EdDSA over Ed25519 by default, or ES256 over P-256), with no W3C VC JSON-LD envelope (`REQ-PR-NOTARY-013`). | Stack; algorithm configured | +| Holder binding | Spec requires binding via `did:jwk` in `cnf` (`REQ-PR-NOTARY-015`); the implementation default is `none` (unbound), with `did` binding forced only on the self-attestation / OID4VCI path. Gap under review (below). | Stack; gap under review | +| Revocation / credential status | An optional, default-disabled status-list surface; `CredentialStatusConfig.enabled` defaults to `false`. No revocation flow is specified by the `RS-*` set. | Operator (optional surface) | +| Federation | Static-peer only: peers loaded from config at startup, an unconfigured peer rejected; the answer is a compact signed JWT carrying a scoped evaluation result, never a credential (`REQ-PR-NOTARY-018`, `REQ-PR-NOTARY-019`). | Stack; peers configured | +| Key custody | The stack never serves the private half (the `PublicJwk` type rejects private members); storage, injection, and rotation of keys are the operator's (`REQ-SEC-G-007`, `REQ-SEC-G-013`, `RS-SEC-G` Section 9). | Stack (public-only surface) + operator | +| Network egress | An outbound SSRF-guard primitive constrains destinations (scheme allowlist, deny private ranges, deny cloud-metadata), applied at `SHOULD` level. The strict default denies private ranges, but a per-connection or per-peer `allow_insecure_private_network` opt-in re-enables private-network HTTP destinations while still denying cloud-metadata. Edge rate limiting and egress limits are the operator's (`REQ-SEC-G-012`, `RS-SEC-G` Section 9). | Stack (primitive) + operator | +| Transport / tenant isolation | Outside the security model; assigned to the operator (`RS-SEC-G` Section 1 and Section 9). | Operator | + +## Authorization in detail + +Authorization is scope-based and deny-by-default. A scope-authorized caller must hold the scope a +route requires, and the service enforces that scope before it reads any source or evaluates any +claim, so a caller that lacks the scope is refused before source access rather than after +(`REQ-SEC-G-005`). On the Relay data plane, access is scope-checked per dataset +(`REQ-PR-RELAY-006`). + +Self-attestation principals are gated differently. `require_claim_access` and +`require_evaluation_access` return early for a self-attestation principal, so the per-source +required-scope check is not applied. The gate before source access is instead the self-attestation +trust-context policy (citizen client or audience match plus `self_attestation.scope_policy`). That +policy defaults to `Required` and enforces the configured scopes, but a deployment can set it to +`Disabled`, in which case no caller scope is required before source access. + +The per-dataset data-plane scopes follow the pattern `:`. The bare suffixes +are `metadata`, `rows`, `aggregate`, `verify`, `evidence_verification`, and `identity_release` +(`crates/registry-relay/src/config/validate.rs`). The implementation also accepts a parameterized +`evidence_verification:` form where the suffix is non-empty, so the data-plane vocabulary +is not closed at exactly six fixed strings. + +Three service-level scopes sit outside the per-dataset pattern and are accepted directly by scope +validation: `registry_relay:admin` (reload and configuration-mutation routes), +`registry_relay:ops_read` (operational posture reads), and `registry_relay:metrics_read` (metrics) +(`validate.rs`; `REQ-PR-RELAY-018`). + +A governed read passes through a policy decision point before governed data is returned. A governed +Relay read must receive a PDP permit before returning entity-backed or aggregate data; on denial +Relay fails closed and returns a stable `pdp.*` problem code rather than falling back to an +ungoverned read (`REQ-PR-RELAY-019`). Scope authorization runs ahead of the PDP gate: an aggregate +route performs ordinary aggregate and source-read scope authorization first, and a scope denial +returns `auth.scope_denied` without reporting PDP policy provenance (`REQ-PR-RELAY-021`). Unscoped +or malformed trust metadata must not satisfy the PDP gate (`REQ-PR-RELAY-020`). + +A service must not widen a caller's reach at request time beyond what configuration grants. Only +liveness and readiness probes and public verification-key discovery (the issuer JWKS and, where +published, the `did:web` document) are served without authentication; every claim-bearing and +record-bearing route requires authentication (`REQ-SEC-G-006`). On the Notary listener the +auth-exempt surface is broader than probes alone: it covers `/healthz`, `/ready`, the evidence +JWKS, the OpenID credential-issuer metadata, the OID4VCI issuance-flow routes, the credential-status +route, the VCT routes, and the docs/OpenAPI routes (the OpenAPI document only when +`openapi_requires_auth` is disabled) (`crates/registry-notary-server/src/standalone.rs`). On the +Relay listener the auth-exempt surface is likewise broader than probes: it covers `/healthz`, +`/ready`, `/docs`, the `/.well-known/api-catalog` linkset, and the OpenAPI document when +`openapi_requires_auth` is disabled, and when `provenance` is enabled it adds the verifier-support +routes `/schemas/...` and `/contexts/...`, plus the `/.well-known/did.json` document in gateway +issuer mode (`REQ-PR-RELAY-013`; `crates/registry-relay/src/server.rs`). The +federation evaluations route is exempt from API-key/OIDC middleware only: the federation handler +still requires and verifies the peer-signed JWS, so delegated evaluation is not unauthenticated. + +A note on coverage: `REQ-SEC-G-002` and `REQ-SEC-G-008` state an invariant a conforming deployment +meets, not a per-route audit of a given build. The posture above is the specified invariant plus +the route-registration evidence, not an enumeration of every route in a running binary. + +## Audit as a control + +Audit is a security control, not best-effort logging. A deployment must be able to run audit +fail-closed, so a request whose audit record cannot be written does not return a successful result, +and a service must not silently drop an audit record on the success path (`REQ-SEC-G-009`). For +Registry Notary, every evaluated request must emit an `EvidenceAuditEvent` in an audit envelope, +and an audit write failure must surface as a request error rather than being swallowed as a silent +log entry (`REQ-PR-NOTARY-020`, `REQ-PR-NOTARY-021`). + +The audit envelope is a hash-chained append record: each `AuditEnvelope` carries an envelope id, a +timestamp, the previous envelope hash, the consumer-owned record body, and the record hash +(`crates/registry-platform-audit/src/lib.rs`). The envelope carries no retention or TTL field, +which is why audit retention and storage are the operator's (`RS-SEC-G` Section 9). `REQ-SEC-G-009` +is phrased as a capability the deployment must be able to enable, so fail-closed audit is a +configurable obligation rather than an unconditional runtime guarantee in every deployment. One +spec-required envelope field, the scopes exercised, is not captured on the Notary record body; see +Known gaps. + +## Credentials and disclosure + +Cryptographic verifiability comes from the issued credential and the signed federation result, not +from the everyday evaluation response. An issued credential is an SD-JWT VC (media type and `typ` +header `dc+sd-jwt`) signed with EdDSA over Ed25519 by default, with no W3C VC JSON-LD envelope +(`REQ-PR-NOTARY-013`). Credential-profile signing keys may be configured +as `EdDSA` or `ES256` over P-256; `RS256` is rejected for credential profiles and is reserved for +the eSignet client-assertion path, while access-token and federation signing remain EdDSA-only +(`crates/registry-notary-core/src/config.rs`). `ES256` is a configurable profile signing +algorithm, not the default issued-credential signature. + +Selective disclosure is digest-based: the signed credential body carries the SHA-256 digest of each +selectively disclosable field rather than the value, so an unselected field stays hidden and a +holder cannot present a disclosure that was not in the original credential (`REQ-PR-NOTARY-014`). +Public claim results, by contrast, are provenance-tagged (`registry-notary-claim-provenance/v1`), +not signed (`REQ-PR-NOTARY-028`). The mechanics of the three disclosure modes, mode selection, and +the minimization discipline live in [Disclosure and minimization](../disclosure-and-minimization/). + +## Federation and keys + +Federation is static-peer only. Peers are loaded from configuration at startup and a request from +an unconfigured peer is rejected: the runtime keys peers by their `iss` claim, and an issuer absent +from the configured set is rejected as an invalid token +(`crates/registry-notary-server/src/federation/`). Delegated evaluation +(`POST /federation/v1/evaluations`) accepts a compact signed JWT request and returns a compact +signed JWT response carrying a scoped evaluation result; before any source read the serving Notary +verifies peer policy, replay state, purpose, profile, and audience (`REQ-PR-NOTARY-018`). The +federated answer is never a credential, and federation reuses the deployment replay store rather +than a separate one: federation replay checks run against the same `replay.storage` store the rest +of the runtime uses, which a multi-instance deployment must back with `redis` so a replay accepted +by one process is rejected by another (`crates/registry-notary-core/src/config.rs`). The shared +replay storage that `REQ-PR-NOTARY-019` places out of scope is replay state coordinated across +federation peers, not a deployment backing its own store with Redis. Dynamic trust-chain discovery, +cross-peer shared replay storage, and federated credential issuance are out of scope for this +version and must not be implied by a conformance claim (`REQ-PR-NOTARY-019`). + +A verifier needs no credential to obtain a public key. The issuer JWKS +(`GET /.well-known/evidence/jwks.json`) is served without authentication (`REQ-PR-NOTARY-002`), and +it exposes public JWKs only: the `PublicJwk` type rejects private key members, so the private half +never appears on the published surface (`crates/registry-platform-crypto/src/lib.rs`). A key +rotated out may remain published so existing results stay verifiable; that is not a revocation +mechanism (`records-stay-home.mdx`). The stack never serving the private half is a property of the +public surface, not a guarantee that the deployment's key storage is secure: how keys are stored, +injected, and rotated (environment, file, or hardware module) is an operator responsibility, and an +implementation must not embed secret material in any portable or distributable artifact +(`REQ-SEC-G-013`, `RS-SEC-G` Section 9). + +## Known gaps + +Two specification requirements diverge from the current implementation. Both are stated here as +spec-says-X, implementation-does-Y, under review, not as resolved decisions. + +- **Holder binding default.** `REQ-PR-NOTARY-015` says a credential MUST bind its holder by naming + the holder's public key as a `did:jwk` in the `cnf` claim, with `did:jwk` the only supported + method and a fresh, audience-bound holder proof required at presentation. The implementation + defaults `holder_binding` mode to `none`, which issues an unbound credential carrying no `cnf` + and requiring no holder proof; `did` binding is forced only on the self-attestation / OID4VCI + credential-profile path, where profile validation rejects any mode other than `did` and requires + `proof_of_possession` (`crates/registry-notary-core/src/config.rs`). The general default + therefore leaves credentials unbound, diverging from the MUST. Under review. +- **Audit scope capture on the Notary path.** `REQ-SEC-G-008` requires the audit envelope to + capture at least the caller principal, the scopes exercised, a request identifier, and the + `Data-Purpose` value where supplied. The Notary `EvidenceAuditEvent` struct records a principal + hash, decision, method, path, status, claim hash, purposes, and a correlation hash, but has no + field capturing the scopes exercised (`crates/registry-notary-core/src/model.rs`). The Relay + `AuditRecord` does carry `scopes_used` (`crates/registry-relay/src/audit/mod.rs`), so the + omission is specific to the Notary record, not stack-wide. Under review. + +## Honest limits + +The following are documented honest framing, not discovered defects. + +- **This is not zero-knowledge.** A `predicate` answer is a policy-enforced boolean computed inside + the service, and SD-JWT selective disclosure is digest omission. Neither is a zero-knowledge + proof, and the documentation should not imply one (`records-stay-home.mdx`). +- **Minimization is modelled, not automatic.** `value` mode is not constrained to a scalar; a claim + modelled to return an object or extracted record returns one. Least disclosure is a design choice + the claim makes, not a property the stack imposes on every answer (`records-stay-home.mdx`). +- **Correctness depends on the configured source.** Notary reports what its configured source says + and does not independently vouch for whether the source is correct or current + (`records-stay-home.mdx`). +- **Several guarantees are the operator's.** Network egress limits, key custody, tenant isolation, + audit retention, and transport security are supplied by the deployment, not guaranteed by the + stack (`records-stay-home.mdx`). +- **The model is specified in draft.** The behaviour above is defined in the `RS-*` specifications, + which are still drafts and may change. Alignment with an external standard is not a claim of + conformance to it or of legal compliance (`records-stay-home.mdx`; `RS-SEC-G`; `RS-PR-NOTARY`). + +## Related + +- The security and protocol contracts: [RS-SEC-G](../../spec/rs-sec-g/), + [RS-ARC-G](../../spec/rs-arc-g/), [RS-PR-RELAY](../../spec/rs-pr-relay/), + [RS-PR-NOTARY](../../spec/rs-pr-notary/) +- The boundary overview: [Records stay home](../records-stay-home/) *(explanation)* +- The disclosure model in depth: [Disclosure and minimization](../disclosure-and-minimization/) *(explanation)*