diff --git a/.env.example b/.env.example index 8bb6afe..db24542 100644 --- a/.env.example +++ b/.env.example @@ -1,28 +1,28 @@ # ============================================================================= -# .env.example — Odal Node engine: the single source of truth for env vars. +# .env.example — Odal Node engine: the single source of truth for env vars. # Copy and fill in: cp .env.example .env -# .env is gitignored — never commit real secrets. +# .env is gitignored — never commit real secrets. # # Organised by deployable. The node (the MVP binary) reads the NODE section. # The resolver is a separate deployable and reads the RESOLVER section from its -# own environment — the vars live here only so everything is in one place. +# own environment — the vars live here only so everything is in one place. # ============================================================================= # ============================================================================= -# NODE — the MVP binary `dpp-node` (vault + identity + integrator on one port, +# NODE — the MVP binary `dpp-node` (vault + identity + integrator on one port, # routed /vault/* /identity/* /integrator/*). This is what `odal up` runs. # ============================================================================= # --- PostgreSQL (required) --------------------------------------------------- # Two passwords, each defined ONCE. Docker Compose reads these to provision the # Postgres container and build the connection URLs: -# DATABASE_POSTGRES_PASS — superuser (container init + migrations) -# DATABASE_APP_PASS — odal_app role the node connects with +# DATABASE_POSTGRES_PASS — superuser (container init + migrations) +# DATABASE_APP_PASS — odal_app role the node connects with DATABASE_POSTGRES_PASS=dev_only_password DATABASE_APP_PASS=dev_only_password # Connection URLs the node binary reads directly. When running the node from # source (dev), set these; the embedded passwords MUST match the two vars above. -# App role: no DDL, no DELETE (one sanctioned exception). Single-tenant — +# App role: no DDL, no DELETE (one sanctioned exception). Single-tenant — # isolation is an infrastructure boundary (one node per operator), not # Row-Level Security. The node refuses to boot if this URL connects as a # superuser: a superuser owns the audit table, so the append-only trigger @@ -49,7 +49,7 @@ LOG_LEVEL=info # Comma-separated CORS origins. Empty = no CORS (server-side API-key access only). CORS_ALLOWED_ORIGINS=http://localhost:3000 # Event bus. Empty = NoOp (events discarded silently). Set to enable NATS -# JetStream — the node connects at boot and fails fast if it can't reach it. +# JetStream — the node connects at boot and fails fast if it can't reach it. NATS_URL=nats://localhost:4222 # Wasm sector-plugin directory. PLUGINS_DIR=./plugins @@ -71,14 +71,14 @@ BATCH_CONCURRENCY=20 # ALLOW_UNSIGNED_PLUGINS=false # ============================================================================= -# RESOLVER — public QR / Digital-Link resolver `dpp-resolver`. +# RESOLVER — public QR / Digital-Link resolver `dpp-resolver`. # A separate deployable (its own container, or a Cloudflare Worker) with its # own environment. These vars are listed here only so everything is in one place; # the node binary ignores them. Use RESOLVER_PORT to avoid collision with # NODE_PORT when both are sourced from the same shell environment. # ============================================================================= RESOLVER_PORT=8003 -REDIS_URL=redis://localhost:6379 # required by the resolver — response cache +REDIS_URL=redis://localhost:6379 # required by the resolver — response cache VAULT_BASE_URL=http://localhost:8001/vault # points at the node's vault sub-path CACHE_TTL_SECS=30 # worst-case recall-propagation window; raise only with that tradeoff in mind RATE_LIMIT_RPM=120 # per-IP request limit @@ -108,7 +108,7 @@ RATE_LIMIT_RPM=120 # per-IP request limit # MTLS_ALLOW_INSECURE=false # ============================================================================= -# eIDAS qualified sealing — eID Easy Cloud Direct e-Sealing (CAdES). +# eIDAS qualified sealing — eID Easy Cloud Direct e-Sealing (CAdES). # # SEAL_PROVIDER unset (or `none`) means GhostSeal: published passports carry no # seal, and NODE_PROFILE=production refuses to boot. `eideasy` is the only other @@ -116,7 +116,7 @@ RATE_LIMIT_RPM=120 # per-IP request limit # migration for every self-hoster. An unrecognised value fails the boot. # # Setting the SEAL_EIDEASY_* credentials WITHOUT selecting the provider is also -# refused — silently ghosting there would downgrade a node that was configured +# refused — silently ghosting there would downgrade a node that was configured # for qualified sealing into one that has none. Same reason a partial # configuration fails, and why SEAL_EIDEASY_BASE_URL is allowlisted to the two # hosts below. @@ -138,7 +138,7 @@ RATE_LIMIT_RPM=120 # per-IP request limit # # The operator's legal name and country come from operator config (`odal # operator`), not from here. Both land on the registration's operator -# identifier, and the registry rejects a registration carrying no legal name — +# identifier, and the registry rejects a registration carrying no legal name — # a node with an unset legal name logs a warning at boot and every registration # fails validation. # EU_REGISTRY_CLIENT_ID= @@ -150,7 +150,7 @@ RATE_LIMIT_RPM=120 # per-IP request limit # # Deliberately separate from SNAPSHOT_S3_BUCKET: writing snapshots to object # storage does not make them reachable. Until an operator states that they are -# served, no back-up link is declared — a URL the registry cannot fetch is worse +# served, no back-up link is declared — a URL the registry cannot fetch is worse # than declaring none. # SNAPSHOT_PUBLIC_BASE_URL=https://backup.example.com/dpp # @@ -158,9 +158,9 @@ RATE_LIMIT_RPM=120 # per-IP request limit # unless explicitly set, so the safe behaviour is the one you get by doing # nothing. It exists because our local rules are an interpretation of the spec # and may produce a false positive that should not need a code change to work -# around — not as a way to push known-bad records at a live registry. +# around — not as a way to push known-bad records at a live registry. # EU_REGISTRY_ALLOW_INVALID_PAYLOADS=false # ============================================================================= -# Ports: Postgres 5432 · NODE_PORT 8001 · RESOLVER_PORT 8003 · Redis 6379 · NATS 4222 -# Metrics (private): node 9100 · resolver 9101 +# Ports: Postgres 5432 · NODE_PORT 8001 · RESOLVER_PORT 8003 · Redis 6379 · NATS 4222 +# Metrics (private): node 9100 · resolver 9101 diff --git a/CHANGELOG.md b/CHANGELOG.md index d57aed4..66a4fed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,6 +115,30 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): ### Added +- **Sealing is answerable from the control plane** — `GET /vault/api/v1/seal` + and `odal seal status [id]`. With an id: that passport's seal, its signing + certificate, and whether it still covers the current signature. Without one: + how many published passports carry no seal, operator-wide. + + Both facts were previously reachable only through Prometheus (`seal_outbox_*` + gauges) or by curling the per-passport route with an id you had to already + know. Neither answers "is anything unsealed", which is the question an + operator actually has. + + **The summary counts passports, not outbox rows**, and the distinction is the + reason for the new `SealOutbox::unsealed_published_count`. The row counts + cannot answer it: `enqueue` runs after the publish commits, so a crash in that + window publishes a passport that no row will ever cover, and an outbox + reporting `pending: 0, exhausted: 0` is consistent with any number of unsealed + passports. A summary built on rows alone would have shown all clear while the + obligation went unmet. It shares the repair sweep's predicate so the two + cannot drift on what "unsealed" means. + + The CLI prints no verdict on the seal itself. The node does not validate the + CAdES and says so; inventing "valid" in the client would manufacture a claim + the API declined to make. `superseded` renders as a fact with its explanation, + not a failure — the seal remains valid for the signature it does cover. + - **eIDAS qualified sealing, end to end** (migration `0028_seal_outbox.sql`). `dpp-seal` becomes a real adapter against eID Easy Cloud Direct e-Sealing, which aggregates qualified QTSPs and returns @@ -140,12 +164,13 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): `(passportId, payloadHash)` rather than by passport alone, because a re-publish re-signs and the new signature needs its own attestation. - **`verify()` is not implemented and says so**, returning a typed error. A - detached CAdES must be validated by an independent AdES validator against the - EU Trusted List — no Rust implementation exists — and this adapter will not - report a seal valid on a check it did not perform. The new route and the - dossier both state the same thing rather than letting their output read as a - verdict. + **`verify()` is not implemented for the hosted backend and says so**, + returning a typed error. The reason is independence, not tooling: a qualified + seal is worth exactly as much as the independence of whoever checked it, so a + verdict issued by the node that bought the seal attests nothing a relying party + should accept. Those seals are checked by an independent AdES validator against + the EU Trusted List. The route and the dossier both state this plainly rather + than letting their output read as a verdict. - **`GET /vault/api/v1/dpp/{dppId}/seal`** returns the qualified seal together with the JWS it covers and that JWS's digest. The seal needs its own route @@ -171,6 +196,62 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): seal at all, so it cannot double-bill. Exhausted rows are held back for six hours so sweep and drain do not hammer an outage together. +- **A local development sealing backend** (`SEAL_PROVIDER=local`). Signs + in-process with a generated P-256 key under a self-signed certificate, + producing a real detached CMS `SignedData` — the same shape a provider returns, + so the whole pipeline is exercised without a provider account, a contract, or a + sandbox credential. The key is persisted between runs, because a restart that + silently invalidated every seal it had produced would teach the wrong thing + about how seals behave. + + **It is not qualified and cannot become so.** The certificate is on no EU + Trusted List, which is a property of the certificate rather than of this code — + nothing in the signing or verification path differs between a self-signed key + and a QTSP-held one. What differs is the legal weight, which is none. The node + states that structurally by resolving this backend to the `Ghost` trust tier, + so a production profile refuses to boot on it while the envelope still drains. + + **This backend does verify its own seals**, and is the only one that does. Its + seals make no trust claim beyond "this key signed this digest", so a + cryptographic check is the whole truth about them and there is no authority + whose independence could be borrowed. It carries the digest in CMS + `signedAttrs` and signs over their DER `SET OF` encoding, as CAdES requires, + which is what makes the envelope self-checking: a holder of the bytes alone + confirms the signature against the certificate travelling inside. `valid: true` + from it means exactly that and nothing about trust. + +- **`signingCertRef` names the certificate a seal was made with.** Read out of + the returned CAdES and surfaced on the seal route, so the question "which + certificate signed this, and was it on the EU Trusted List at the time?" no + longer requires being handed the `.p7s` and parsing it by hand. + + **Reported by the seal, never verified.** It answers *which* certificate to ask + about and nothing else — no chain is built, no Trusted List consulted, no + revocation checked. A convenience field that read as verification while + verifying nothing would be worse than an absent one, because an absent field + prompts the question and a populated one settles it wrongly. + + A hex SHA-256 thumbprint rather than issuer+serial or a subject key identifier: + one fixed-length value naming exactly one certificate, comparable without + parsing, and the same thing the local backend already reports — a test asserts + the two agree, since a field that means different things per backend is not a + key anyone can match on. A seal the parser cannot read still stores, with the + reference left `null`. + +- **`sealedPayloadHash` and `coverage` on the seal route.** The seal envelope + records no preimage, so a node could not previously tell a current seal from + one superseded by a later re-publish — it could only hand both digests to an + external validator. But the outbox row that bought the seal does carry the + preimage and is never deleted, so the answer was already held and only ever a + query away. `coverage` reports `current`, `superseded`, or `unknown`. + + This is the node's own record of what it *asked* to seal, not proof of what the + CAdES covers; only an independent validator establishes the latter, and the two + agreeing is the cross-check. `unknown` is deliberately distinct from + `superseded`: a seal restored from a backup is very likely current, and + branding it stale on the strength of a missing row would be the same error in + the opposite direction. + - **A clock-skew hint on authentication failures.** `X-Timestamp` is inside the signed message and eID Easy allows five minutes of drift, so a wrong node clock produces a 401 byte-for-byte identical to a bad key. The adapter now compares @@ -179,15 +260,43 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md): good credential and the failure persists. `429` is likewise typed separately from a generic provider error, with `Retry-After` surfaced when present. +### Breaking + +- **`NODE_PROFILE=production` now refuses a `Sandbox` trust tier, not only a + ghost.** A production node asserts that its passports are backed by real + authorities; admitting a sandbox tier made that untrue, and a provider's test + certificate could seal a passport claiming to be real. The tiers are ordered + `Ghost < Sandbox < Live` so a profile states a floor rather than enumerating + what it rejects — adding a tier later cannot silently pass an existing guard. + + **Migration.** A node running `NODE_PROFILE=production` against a provider's + test environment now fails to boot, naming the ports that resolved too low. + Either set the new **`NODE_PROFILE=sandbox`** — a full node in every respect + except that the authorities behind it are test ones, and still a hard boot + failure on ghosts — or point the backend at its production endpoint with + production credentials. Sandbox is deliberately a property of the *deployment* + rather than a tier a production node may quietly carry: running it as its own + environment is the closest rehearsal of production there is, and keeping the + two apart is what stops a test certificate ever sealing a real passport. + ### Changed -- **Seal configuration is provider-neutral**: `SEAL_PROVIDER=eideasy|none` plus - `SEAL_EIDEASY_*`, replacing the bare `EIDEASY_*` names. Env var names are a - published interface, so a second QTSP should be a new value rather than a - config migration for every self-hoster. An unrecognised provider fails the - boot, as does setting the credentials without selecting the provider — both - would otherwise downgrade a node configured for qualified sealing into one - with none. +- **Seal configuration is provider-neutral**: `SEAL_PROVIDER=eideasy|local|none` + plus `SEAL_EIDEASY_*` / `SEAL_LOCAL_*`, replacing the bare `EIDEASY_*` names. + Env var names are a published interface, so a second QTSP should be a new value + rather than a config migration for every self-hoster. An unrecognised provider + fails the boot, as does setting one backend's credentials without selecting it + — both would otherwise downgrade a node configured for qualified sealing into + one with none. + + Backends sit behind a `SealBackend` trait, each owning its own module, + variables, validation, wire types and failure classification. Nothing outside a + backend's module names it: the adapter holds one `dyn SealBackend` and the + selector maps one environment value to one module, so adding a provider is + additive and removing one leaves nothing behind. `SealProvider` is deliberately + not `#[non_exhaustive]` — adding a backend should break every wiring site + rather than fall into a `_` arm, which is how a node silently seals with + something other than what it was asked for. - **`seal` joins the retention guard's mutable keys** (`0028_seal_outbox.sql`) and `MUTABLE_FIELDS`. Without it the drain's write to an already-published, diff --git a/CLAUDE.md b/CLAUDE.md index 2bb09ac..ae1efbb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,9 +155,10 @@ dpp-integrator — CSV/XLSX bulk import dpp-common — event bus trait, telemetry, config helpers, RFC 7807 errors dpp-plugin-host — wasmtime sandbox for sector Wasm plugins dpp-node — MVP single binary fusing vault + identity + integrator -dpp-seal — eIDAS qualified seal adapter: eID Easy Cloud Direct e-Sealing - (CAdES) with a GhostSeal fallback. Wired into dpp-node, but the - drain only arms against a real QTSP — see the sealing_live guard +dpp-seal — eIDAS qualified seal adapter: one `SealBackend` behind the + `SealPort`, selected by SEAL_PROVIDER (hosted QTSP / local dev + sealer / ghost). Only the hosted one has legal weight; the drain + arms for any backend that emits a real envelope (sealing_live) dpp-factor-data — licensed LCI factor data store: GhostFactorProvider + FactorStore trait (no dependent yet) cli/ — management CLI (clap); package `dpp-cli`, binary `odal` ``` @@ -316,6 +317,7 @@ Background cleanup task runs every 6 hours, deleting completed/failed jobs older | POST | `/vault/api/v1/dpp/{dppId}/archive` | Bearer | Archive | | GET | `/vault/api/v1/dpp/{dppId}/history` | Bearer | Audit trail | | GET | `/vault/api/v1/dpp/{dppId}/seal` | Bearer | eIDAS qualified seal + the JWS/digest it covers (`404` when unsealed) | +| GET | `/vault/api/v1/seal` | Bearer | Operator-wide sealing state — published passports carrying no seal, plus outbox totals | | GET | `/vault/api/v1/dpp/{dppId}/stats` | Bearer | Per-passport scan telemetry (aggregate; scans + qrRenders, never summed) | | GET | `/vault/api/v1/stats` | Bearer | Operator-wide scan telemetry rollup | | POST | `/vault/internal/scan-batch` | mTLS (`CN=odal-resolver`) | Resolver scan-telemetry flush sink (off public + `/api/v1`) | diff --git a/Cargo.lock b/Cargo.lock index f6f52d1..0a88885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -233,6 +233,45 @@ dependencies = [ "zbus", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "astral-tokio-tar" version = "0.6.4" @@ -864,7 +903,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] [[package]] @@ -873,6 +912,15 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1293,6 +1341,18 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "x509-cert", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1916,10 +1976,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2455,17 +2542,24 @@ dependencies = [ "axum", "base64 0.22.1", "chrono", + "cms", + "const-oid 0.9.6", + "der", "dpp-domain", "hex", "hmac 0.12.1", + "p256", + "rcgen", "reqwest", "serde", "serde_json", "sha2 0.10.9", + "tempfile", "thiserror 2.0.19", "tokio", "tracing", "url", + "x509-cert", "zeroize", ] @@ -2844,6 +2938,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -4075,6 +4175,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -4129,6 +4235,16 @@ dependencies = [ "signatory", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4313,6 +4429,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -4446,6 +4571,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4643,7 +4778,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", - "bit-vec", + "bit-vec 0.8.0", "bitflags 2.13.1", "num-traits", "rand 0.9.5", @@ -4958,6 +5093,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rcgen" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redis" version = "1.5.0" @@ -5206,6 +5355,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -6277,6 +6435,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -7826,6 +8005,36 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "tls_codec", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.19", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -7848,6 +8057,16 @@ version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec 0.9.1", + "time", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 83fbcba..ef00b1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ flate2 = "1" ring = "0.17" base64 = "0.22" hex = "0.4" -sha2 = "0.10" +sha2 = { version = "0.10", features = ["oid"] } hmac = "0.12" subtle = "2" ed25519-dalek = { version = "2", features = ["rand_core"] } diff --git a/README.md b/README.md index 88d20a9..3159dac 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ The engine ships as a **single binary** (`dpp-node`) that fuses all services und | `dpp-plugin-host` | lib | wasmtime sandbox — fuel metering, memory cap, deny-all WASI, signed-plugin policy | | `dpp-node` | bin | **The single binary — fuses all services**, boot trust-report, registry outbox drain, signed-ruleset loader | | `dpp-cli` (`cli/`) | bin | `odal` — the operator control plane, from bootstrap to evidence dossier generation and verification | -| `dpp-seal` | lib | eIDAS qualified-seal adapter (CSC/QTSP client scaffold) — resolves to a clearly-marked Ghost until a QTSP is configured; a production-profile node **refuses to boot** on ghost trust adapters | +| `dpp-seal` | lib | eIDAS qualified-seal adapter over one `SealBackend`, chosen by `SEAL_PROVIDER`: a hosted QTSP, a local development sealer (real CMS, no legal weight), or a clearly-marked Ghost when unset. Only the first carries legal weight — a production-profile node **refuses to boot** on the others | | `dpp-factor-data` | lib | Licensed LCI factor store — ghost provider until a dataset licence is signed; any ghost-derived result is marked `dataset_id="ghost"` | ### Dependencies on dpp-core diff --git a/api/openapi.yaml b/api/openapi.yaml index 2b887d5..4266637 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2005,6 +2005,78 @@ paths: "404": $ref: "#/components/responses/NotFound" + /vault/api/v1/seal: + get: + operationId: getSealSummary + summary: Operator-wide sealing state + description: | + How many published passports carry no seal, plus the outbox totals + behind that number. + + `unsealedPublished` is the headline; the three row counts are context. + They answer different questions and can legitimately disagree: the + counts describe outbox **rows**, while the obligation is about + **passports**. Enqueueing happens after the publish commits, so a crash + in that window publishes a passport that no row will ever cover — + `pending: 0, exhausted: 0` is therefore consistent with any number of + unsealed passports, and a summary built on rows alone would report all + clear. A repair sweep queues those passports on its next pass. + + A passport whose seal covers a *superseded* signature is not counted + here — it carries a seal, and that seal remains a valid attestation of + the signature it was bought for. `GET /vault/api/v1/dpp/{dppId}/seal` + reports that case per passport as `coverage`. + + When `sealingConfigured` is `false` no seal provider is selected, so + every count is `0` because this node has no outbox — not because it has + nothing outstanding. + tags: [DPP Management] + security: + - BearerApiKey: [] + - BasicAuth: [] + responses: + "200": + description: Operator-wide sealing state. + content: + application/json: + schema: + type: object + required: + [ + unsealedPublished, + pending, + sealed, + exhausted, + sealingConfigured, + ] + properties: + unsealedPublished: + type: integer + format: int64 + description: >- + Published passports carrying no seal at all. `0` is the + healthy state. + example: 0 + pending: + type: integer + format: int64 + description: Outbox rows awaiting a sealing attempt. + sealed: + type: integer + format: int64 + description: Outbox rows whose seal is on the passport. + exhausted: + type: integer + format: int64 + description: Outbox rows that gave up after exhausting retries. + sealingConfigured: + type: boolean + description: >- + False when no seal provider is configured, in which case + every count above is `0` for that reason alone. + "401": + $ref: "#/components/responses/Unauthorized" + /vault/api/v1/dpp/{dppId}/seal: get: operationId: getDppSeal @@ -2019,12 +2091,18 @@ paths: verifies against nothing they received. **This node does not validate the seal.** A detached CAdES must be - checked by an independent AdES validator against the EU Trusted List, and - that check is also what establishes whether the seal covers - `currentPayloadHash` — the validator extracts the signed message digest - and compares. A mismatch means the passport was re-published after - sealing and a seal over the new signature has not landed yet; the seal - remains valid for the signature it does cover. + checked by an independent AdES validator against the EU Trusted List. A + verdict from the node that bought the seal would attest nothing, so none + is offered. + + `coverage` answers a narrower question that the node *can* answer, from + its own records: `sealedPayloadHash` is the digest it asked the backend + to seal, so a passport re-published after sealing shows as `superseded` + without any AdES tooling. That is a record of what was requested, not + proof of what the CAdES covers — the validator's extracted message + digest is the cross-check. A `superseded` seal remains valid for the + signature it does cover; a seal over the new signature has not landed + yet. `404` when the passport has no seal — it may be unpublished, its seal may still be queued, or the node may have no QTSP configured. An unsealed @@ -2051,9 +2129,12 @@ paths: format, sealValue, sealedAt, + signingCertRef, placeholder, currentJws, currentPayloadHash, + sealedPayloadHash, + coverage, verification, ] properties: @@ -2067,6 +2148,19 @@ paths: sealedAt: type: string format: date-time + signingCertRef: + type: [string, "null"] + description: | + Hex SHA-256 of the certificate the seal names as its + signer, **as reported by the seal** — read out of the CAdES + structure, never verified. + + It answers *which* certificate to ask about, not whether + that certificate was qualified or on the EU Trusted List + when the seal was made; both are the independent + validator's question. `null` when the seal predates + extraction or could not be parsed. + pattern: "^[0-9a-f]{64}$" placeholder: type: boolean description: | @@ -2078,11 +2172,28 @@ paths: currentPayloadHash: type: string description: | - Hex SHA-256 of `currentJws`. Labelled "current" rather than - "sealed" deliberately: the seal envelope does not record its - own preimage, so this is one half of the comparison and the - external validator supplies the other. + Hex SHA-256 of `currentJws` — the digest a seal over this + passport's present signature would be taken over. + pattern: "^[0-9a-f]{64}$" + sealedPayloadHash: + type: [string, "null"] + description: | + Hex SHA-256 this node asked the backend to seal, from the + outbox row that bought `sealValue`. `null` when the node + holds no such row — a seal restored from a backup or + produced elsewhere. pattern: "^[0-9a-f]{64}$" + coverage: + type: string + enum: [current, superseded, unknown] + description: | + Whether the stored seal covers the passport's current + signature, per this node's own records. + + `current` — the requested digest is the passport's current + one. `superseded` — the passport was re-published after + this seal was bought. `unknown` — no record; only the + external validator can answer. verification: type: string description: What was and was not checked by this node. diff --git a/cli/src/cli_args.rs b/cli/src/cli_args.rs index e44b521..65f2679 100644 --- a/cli/src/cli_args.rs +++ b/cli/src/cli_args.rs @@ -122,6 +122,12 @@ pub enum Commands { /// Stored dossier id, or path to a dossier JSON file target: String, }, + // ── Qualified seals ────────────────────────────────────────────────────── + /// eIDAS qualified seal inspection + Seal { + #[command(subcommand)] + command: SealCommands, + }, // ── Insight ────────────────────────────────────────────────────────────── /// Operator-wide scan telemetry — how often your passports were resolved /// (per-passport detail: `odal passport stats `) @@ -426,3 +432,17 @@ pub enum WebhookCommands { id: String, }, } + +#[derive(Subcommand)] +pub enum SealCommands { + /// Show sealing state. With no ID: how many published passports are + /// unsealed, operator-wide. With an ID: that passport's seal, its signing + /// certificate, and whether it still covers the current signature + Status { + /// Passport ID. Omit for the operator-wide summary. + id: Option, + /// Output the raw route response instead of a summary + #[arg(long)] + json: bool, + }, +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index e455120..8299c6a 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod plugin; pub mod profile; pub mod publish; pub mod schema; +pub mod seal; pub mod stats; pub mod status; pub mod up; diff --git a/cli/src/commands/seal.rs b/cli/src/commands/seal.rs new file mode 100644 index 0000000..7e5a4c4 --- /dev/null +++ b/cli/src/commands/seal.rs @@ -0,0 +1,44 @@ +//! `odal seal status [id]` — what the node knows about qualified sealing, +//! operator-wide or for one passport. + +use anyhow::Result; + +use crate::{ + config::Config, + core::seal::{SealStatus, action_seal_status, action_seal_summary}, + http::OdalClient, + stateless::render::{render_seal_absent, render_seal_status, render_seal_summary}, +}; + +pub async fn run_seal_status(id: Option<&str>, json: bool) -> Result<()> { + let cfg = Config::load()?; + let client = OdalClient::new(&cfg.api_key); + + let Some(id) = id else { + let summary = action_seal_summary(&client, &cfg).await?; + if json { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else { + render_seal_summary(&summary); + } + return Ok(()); + }; + + match action_seal_status(id, &client, &cfg).await? { + SealStatus::Present(seal) => { + if json { + println!("{}", serde_json::to_string_pretty(&seal)?); + } else { + render_seal_status(&seal, id); + } + } + SealStatus::Absent => { + if json { + println!("{}", serde_json::json!({ "seal": null })); + } else { + render_seal_absent(id); + } + } + } + Ok(()) +} diff --git a/cli/src/core/mod.rs b/cli/src/core/mod.rs index 57d3dd7..8d73f48 100644 --- a/cli/src/core/mod.rs +++ b/cli/src/core/mod.rs @@ -6,6 +6,7 @@ pub mod passport; pub mod plugin; pub mod registry_identity; pub mod schema; +pub mod seal; pub mod types; pub mod verify; pub mod webhook; diff --git a/cli/src/core/seal.rs b/cli/src/core/seal.rs new file mode 100644 index 0000000..95de67a --- /dev/null +++ b/cli/src/core/seal.rs @@ -0,0 +1,146 @@ +//! Qualified-seal inspection via the node API. Pure HTTP — no direct DB access. +//! +//! Read-only by design. Sealing is driven by the publish outbox and its drain, +//! so there is no "seal this now" here: a command that bought a seal out of band +//! would spend money on a third-party call outside the record the drain keeps. + +use anyhow::{Context, Result, bail}; +use reqwest::StatusCode; +use serde_json::Value; + +use crate::{ + config::Config, + http::{OdalClient, describe_error}, +}; + +/// The state of a passport's seal, as far as the node can honestly report it. +/// +/// `Absent` is a first-class answer, not an error. The route returns `404` both +/// for "no such passport" and "this passport has no seal", and those are very +/// different facts to a reader — the first is a mistake, the second is the +/// normal state of a draft, or of a passport whose seal has not drained yet. +pub enum SealStatus { + /// The passport carries a seal. The raw route body, rendered by the caller. + Present(Box), + /// The passport exists but has no seal. + Absent, +} + +/// What a pair of route outcomes means. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Verdict { + /// Read and render the seal body. + Sealed, + /// The passport is real and simply has no seal. + Unsealed, + /// No passport with that id. + NoSuchPassport, + /// Anything else — report the transport error. + Failed, +} + +/// The disambiguation rule, as a pure function over the two status codes. +/// +/// Split out from the request path for the same reason the seal route's own +/// `coverage_of` is: the whole rule is which of four answers a pair of statuses +/// warrants, and that should not need a running node to exercise. +/// +/// `passport` is `None` when the seal route did not `404`, because in that case +/// the second request is never made. +/// +/// Keyed on status codes, never on the error prose. The route's wording is +/// written for a human reading it and is free to change; matching a substring +/// of it would make this command break on a copy edit. +fn classify(seal: StatusCode, passport: Option) -> Verdict { + match (seal, passport) { + (s, _) if s.is_success() => Verdict::Sealed, + (StatusCode::NOT_FOUND, Some(p)) if p.is_success() => Verdict::Unsealed, + (StatusCode::NOT_FOUND, Some(StatusCode::NOT_FOUND)) => Verdict::NoSuchPassport, + _ => Verdict::Failed, + } +} + +/// `GET /api/v1/dpp/{id}/seal` — the seal, its preimage, and its coverage. +pub async fn action_seal_status(id: &str, client: &OdalClient, cfg: &Config) -> Result { + let (status, body) = client + .get(&format!("{}/api/v1/dpp/{id}/seal", cfg.vault_url)) + .await?; + + // Only ask about the passport when the seal route 404s — the one case where + // the answer is ambiguous. + let passport_status = if status == StatusCode::NOT_FOUND { + let (s, _) = client + .get(&format!("{}/api/v1/dpp/{id}", cfg.vault_url)) + .await?; + Some(s) + } else { + None + }; + + match classify(status, passport_status) { + Verdict::Sealed => { + let doc: Value = serde_json::from_str(&body).context("seal response was not JSON")?; + Ok(SealStatus::Present(Box::new(doc))) + } + Verdict::Unsealed => Ok(SealStatus::Absent), + Verdict::NoSuchPassport => bail!("No passport with id {id}."), + Verdict::Failed => bail!( + "Failed to fetch the seal: {}", + describe_error(status, &body) + ), + } +} + +/// `GET /api/v1/seal` — operator-wide sealing state. +pub async fn action_seal_summary(client: &OdalClient, cfg: &Config) -> Result { + let (status, body) = client + .get(&format!("{}/api/v1/seal", cfg.vault_url)) + .await?; + if !status.is_success() { + bail!( + "Failed to fetch the sealing summary: {}", + describe_error(status, &body) + ); + } + serde_json::from_str(&body).context("sealing summary was not JSON") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_seal_body_is_a_seal() { + assert_eq!(classify(StatusCode::OK, None), Verdict::Sealed); + } + + /// The distinction this command exists to make: both are `404` from the + /// seal route, and they mean opposite things to the operator reading it. + #[test] + fn the_two_404s_are_told_apart_by_the_passport_not_the_prose() { + assert_eq!( + classify(StatusCode::NOT_FOUND, Some(StatusCode::OK)), + Verdict::Unsealed + ); + assert_eq!( + classify(StatusCode::NOT_FOUND, Some(StatusCode::NOT_FOUND)), + Verdict::NoSuchPassport + ); + } + + /// An expired key 401s on both routes. Reporting that as "no such passport" + /// would send the operator hunting for a passport that is sitting right + /// there — so anything that is not a clean 404 pair is a transport failure. + #[test] + fn an_auth_failure_is_not_a_missing_passport() { + assert_eq!(classify(StatusCode::UNAUTHORIZED, None), Verdict::Failed); + assert_eq!( + classify(StatusCode::NOT_FOUND, Some(StatusCode::UNAUTHORIZED)), + Verdict::Failed + ); + assert_eq!( + classify(StatusCode::INTERNAL_SERVER_ERROR, None), + Verdict::Failed + ); + } +} diff --git a/cli/src/dispatch.rs b/cli/src/dispatch.rs index 0aa5994..0711b56 100644 --- a/cli/src/dispatch.rs +++ b/cli/src/dispatch.rs @@ -2,7 +2,8 @@ use crate::cli_args::{ Commands, FacilityCommands, KeyCommands, OperatorCommands, OperatorIdCommands, - PassportCommands, PluginCommands, ProfileCommands, SchemaCommands, WebhookCommands, + PassportCommands, PluginCommands, ProfileCommands, SchemaCommands, SealCommands, + WebhookCommands, }; use crate::commands::{ bootstrap::run_bootstrap, @@ -29,6 +30,7 @@ use crate::commands::{ }, publish::run_publish, schema::run_schema, + seal::run_seal_status, stats::{run_operator_stats, run_passport_stats}, status::run_status, up::run_up, @@ -264,6 +266,9 @@ pub async fn dispatch(cmd: Commands) -> anyhow::Result<()> { command: SchemaCommands::Check, } => run_schema().await, Commands::Verify { target } => run_verify(&target).await, + Commands::Seal { + command: SealCommands::Status { id, json }, + } => run_seal_status(id.as_deref(), json).await, Commands::Stats { days, json } => run_operator_stats(days, json).await, } } diff --git a/cli/src/stateless/render.rs b/cli/src/stateless/render.rs index cc7bc00..5c4a026 100644 --- a/cli/src/stateless/render.rs +++ b/cli/src/stateless/render.rs @@ -405,3 +405,135 @@ pub fn render_schema_check(result: &SchemaCheckResult) { if result.update_available { "yes" } else { "no" } ); } + +/// Render a passport's qualified-seal status. +/// +/// Two things this deliberately does not do. It does not say "valid": the node +/// did not validate the CAdES and the route says so, so printing a verdict here +/// would invent one the API refused to give. And it does not collapse +/// `coverage` into a pass/fail — `superseded` is not a failure, it means the +/// passport was re-published after sealing and the seal still covers the +/// signature it was bought for. +pub fn render_seal_status(seal: &serde_json::Value, id: &str) { + let s = |key: &str| { + seal.get(key) + .and_then(serde_json::Value::as_str) + .unwrap_or("-") + .to_owned() + }; + let placeholder = seal + .get("placeholder") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + println!("Seal for {id}"); + + if placeholder { + println!( + " {} no QTSP is configured, so this is a ghost placeholder with no legal weight", + style("PLACEHOLDER").yellow().bold() + ); + } + + println!(" Format : {}", s("format")); + println!(" Sealed at : {}", s("sealedAt")); + + // The certificate the seal names as its signer — which certificate to ask + // about, not whether it was qualified. Absent for seals made before the + // extraction landed, or when the CAdES could not be parsed. + match seal + .get("signingCertRef") + .and_then(serde_json::Value::as_str) + { + Some(cert) => println!(" Signing cert : {cert}"), + None => println!(" Signing cert : not recorded (predates extraction, or unparseable)"), + } + + let coverage = s("coverage"); + let note = match coverage.as_str() { + "current" => "covers the passport's current signature".to_owned(), + "superseded" => "the passport was re-published after sealing — the seal still covers the \ + signature it was bought for, and a seal over the new one has not landed yet" + .to_owned(), + "unknown" => "this node has no record of what was sealed — restored from a backup, \ + or produced elsewhere" + .to_owned(), + other => format!("unrecognised coverage value `{other}`"), + }; + println!(" Coverage : {coverage} — {note}"); + + println!(" Current hash : {}", s("currentPayloadHash")); + println!( + " Sealed hash : {}", + seal.get("sealedPayloadHash") + .and_then(serde_json::Value::as_str) + .unwrap_or("(no record)") + ); + + println!("\n Verification : {}", s("verification")); +} + +/// Render the "this passport has no seal" case. +/// +/// Its own function rather than a branch inside [`render_seal_status`], because +/// there is no seal document to render and the useful content is entirely +/// different: why there might not be one yet. +pub fn render_seal_absent(id: &str) { + println!("Seal for {id}"); + println!(" None. The passport may be unpublished, or its seal may still be queued."); + println!(" Sealing runs off a drain after publish — it is not part of the publish call."); +} + +/// Render the operator-wide sealing summary. +/// +/// Leads with the passport count, not the row counts. An operator asking about +/// sealing is asking whether a published passport is missing its seal; the +/// outbox totals are how that came about, which is the second question. +pub fn render_seal_summary(summary: &serde_json::Value) { + let n = |key: &str| { + summary + .get(key) + .and_then(serde_json::Value::as_i64) + .unwrap_or(0) + }; + + if !summary + .get("sealingConfigured") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) + { + println!("Sealing is not configured on this node."); + println!(" No seal provider is selected, so nothing is queued and nothing is sealed."); + println!(" Set SEAL_PROVIDER to enable it."); + return; + } + + let unsealed = n("unsealedPublished"); + println!("Sealing"); + if unsealed == 0 { + println!( + " {} every published passport carries a seal", + style("OK").green().bold() + ); + } else { + println!( + " {} {unsealed} published passport(s) carry no seal", + style("UNSEALED").red().bold() + ); + } + println!( + " Outbox: {} pending, {} sealed, {} exhausted", + n("pending"), + n("sealed"), + n("exhausted") + ); + + // Worth saying out loud: these two can disagree, and the direction of the + // disagreement is the diagnosis. + if unsealed > 0 && n("pending") == 0 && n("exhausted") == 0 { + println!( + "\n Unsealed with an empty outbox — those passports have no row at all, so no\n \ + drain will pick them up. The repair sweep queues them on its next pass." + ); + } +} diff --git a/crates/dpp-dal/src/pg/repo_seal.rs b/crates/dpp-dal/src/pg/repo_seal.rs index ba21252..f8e8541 100644 --- a/crates/dpp-dal/src/pg/repo_seal.rs +++ b/crates/dpp-dal/src/pg/repo_seal.rs @@ -201,6 +201,26 @@ impl SealOutbox for PgSealOutboxRepo { Ok(()) } + async fn sealed_digest(&self, passport_id: PassportId) -> Result, DppError> { + // A re-published passport accumulates one `sealed` row per signature it + // has carried, so "which seal is on the passport" is the newest of them. + // `id` breaks a `sealed_at` tie: it is UUID v7, so ties order by + // insertion, and `sealed_at` is `now()` — transaction start — which two + // rows closed in the same transaction would share. + let row = sqlx::query( + r#"SELECT payload_hash FROM odal.seal_outbox + WHERE passport_id = $1 AND status = 'sealed' + ORDER BY sealed_at DESC, id DESC + LIMIT 1"#, + ) + .bind(passport_id.0) + .fetch_optional(self.dal.pool()) + .await + .map_err(db_err)?; + + Ok(row.map(|r| r.get::("payload_hash"))) + } + async fn mark_attempt_failed(&self, id: Uuid, message: String) -> Result<(), DppError> { // Exponential backoff on the *new* attempt count, capped at 1h, with // 0.75–1.25× jitter — identical to the registry-sync, webhook and @@ -259,4 +279,27 @@ impl SealOutbox for PgSealOutboxRepo { exhausted: row.get::("exhausted"), }) } + + async fn unsealed_published_count(&self) -> Result { + // The same three conditions `enqueue_unsealed` selects on, and only + // those. Its two `NOT EXISTS` guards hold back rows the drain already + // owns or that failed recently — they shape *when to retry*, not whether + // a passport is unsealed, and applying them here would report a queued + // passport as sealed. + // + // No digest comparison, matching the sweep: a seal over a superseded + // signature is still a valid attestation of the signature it covers, and + // `/dpp/{id}/seal` reports that per passport as `coverage`. + let row = sqlx::query( + r#"SELECT count(*) AS unsealed + FROM odal.passport p + WHERE p.published_at IS NOT NULL + AND p.doc->'seal' IS NULL + AND p.doc->>'jwsSignature' IS NOT NULL"#, + ) + .fetch_one(self.dal.pool()) + .await + .map_err(db_err)?; + Ok(row.get::("unsealed")) + } } diff --git a/crates/dpp-dal/tests/pg_seal_outbox.rs b/crates/dpp-dal/tests/pg_seal_outbox.rs index 4b24b12..7e477fd 100644 --- a/crates/dpp-dal/tests/pg_seal_outbox.rs +++ b/crates/dpp-dal/tests/pg_seal_outbox.rs @@ -15,6 +15,11 @@ //! drained row is a paid QTSP call, so this key is a billing control. //! 4. **`mark_sealed` is atomic** and lands the envelope on the right passport. //! 5. **The `payload_hash` CHECK** refuses anything that is not a SHA-256 digest. +//! 6. **The sealed digest survives the row being closed**, and follows the newest +//! seal across a re-publish. `SealedEnvelope` records no preimage, so this row +//! is the only thing that lets the node tell a current seal from a stale one +//! without an external validator — and it is only worth anything if it keeps +//! pointing at the seal actually on the passport. #![cfg(feature = "integration-tests")] @@ -189,6 +194,117 @@ async fn a_seal_lands_on_a_retention_locked_published_passport() { assert_eq!(counts.pending, 0); } +/// The preimage survives the drain, so a stale seal is detectable without a +/// validator. +/// +/// This is the whole point of reading it back out of the outbox: `SealedEnvelope` +/// records no preimage, but the row that bought the seal does and is never +/// deleted. Without this the node cannot distinguish a current seal from one +/// taken over a signature the passport no longer carries. +#[tokio::test] +async fn the_sealed_digest_is_recoverable_after_the_row_is_closed() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + let outbox = PgSealOutboxRepo::new(pg.dal.clone()); + + let jws = "header.payload.signature"; + let passport = published_passport(jws); + let id = passport.id; + repo.create(passport).await.expect("insert passport"); + + assert_eq!( + outbox.sealed_digest(id).await.expect("lookup"), + None, + "nothing is sealed yet, so there is nothing to report" + ); + + let hash = digest_of(jws); + outbox.enqueue(id, &hash).await.expect("enqueue"); + let due = outbox.due(10).await.expect("due"); + outbox + .mark_sealed(due[0].id, &envelope("BASE64-CADES-P7S")) + .await + .expect("seal"); + + assert_eq!( + outbox.sealed_digest(id).await.expect("lookup"), + Some(hash), + "the digest that was sealed must outlive the row being closed" + ); +} + +/// After a re-publish, the answer is the seal that is actually on the passport. +/// +/// A re-published passport accumulates one `sealed` row per signature it has +/// carried. Returning the older one would report a current seal as stale and a +/// stale one as current — worse than returning nothing. +#[tokio::test] +async fn the_sealed_digest_follows_the_newest_seal() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + let outbox = PgSealOutboxRepo::new(pg.dal.clone()); + + let passport = published_passport("a.b.first"); + let id = passport.id; + repo.create(passport).await.expect("insert passport"); + + let first = digest_of("a.b.first"); + outbox.enqueue(id, &first).await.expect("first publish"); + let due = outbox.due(10).await.expect("due"); + outbox + .mark_sealed(due[0].id, &envelope("SEAL-OVER-FIRST")) + .await + .expect("seal the first signature"); + + let second = digest_of("a.b.second"); + outbox.enqueue(id, &second).await.expect("re-publish"); + let due = outbox.due(10).await.expect("due"); + assert_eq!(due.len(), 1, "only the new digest is due"); + outbox + .mark_sealed(due[0].id, &envelope("SEAL-OVER-SECOND")) + .await + .expect("seal the new signature"); + + assert_eq!( + outbox.sealed_digest(id).await.expect("lookup"), + Some(second), + "the reported digest must match the envelope now on the passport" + ); +} + +/// One passport's seal is never reported for another. +#[tokio::test] +async fn the_sealed_digest_does_not_leak_across_passports() { + let pg = start_pg().await; + let repo = PgPassportRepo::new(pg.dal.clone()); + let outbox = PgSealOutboxRepo::new(pg.dal.clone()); + + let sealed = published_passport("a.b.sealed"); + let sealed_id = sealed.id; + repo.create(sealed).await.expect("insert sealed"); + let unsealed = published_passport("a.b.unsealed"); + let unsealed_id = unsealed.id; + repo.create(unsealed).await.expect("insert unsealed"); + + let hash = digest_of("a.b.sealed"); + outbox.enqueue(sealed_id, &hash).await.expect("enqueue"); + let due = outbox.due(10).await.expect("due"); + outbox + .mark_sealed(due[0].id, &envelope("SEAL")) + .await + .expect("seal"); + + assert_eq!( + outbox.sealed_digest(sealed_id).await.expect("lookup"), + Some(hash) + ); + assert_eq!( + outbox.sealed_digest(unsealed_id).await.expect("lookup"), + None, + "an unsealed passport must not inherit a neighbour's digest" + ); +} + /// Widening `mutable_keys` must not have widened it for anything else. #[tokio::test] async fn the_retention_guard_still_refuses_content_changes() { diff --git a/crates/dpp-node/src/boot/tasks.rs b/crates/dpp-node/src/boot/tasks.rs index 82fd8d5..de858ac 100644 --- a/crates/dpp-node/src/boot/tasks.rs +++ b/crates/dpp-node/src/boot/tasks.rs @@ -314,7 +314,7 @@ fn set_seal_gauges(c: &SealOutboxCounts) { pub async fn spawn_seal_drain( outbox: Arc, seal: Arc, - client_id: String, + key_ref: dpp_domain::ports::seal::SealCredentialRef, ) { match outbox.status_counts().await { Ok(c) => { @@ -332,7 +332,7 @@ pub async fn spawn_seal_drain( tokio::spawn(async move { loop { tokio::time::sleep(DRAIN_INTERVAL).await; - dpp_node::infra::seal_drain::drain_once(&outbox, &seal, &client_id, DRAIN_BATCH).await; + dpp_node::infra::seal_drain::drain_once(&outbox, &seal, &key_ref, DRAIN_BATCH).await; if let Ok(c) = outbox.status_counts().await { set_seal_gauges(&c); if c.exhausted > 0 { diff --git a/crates/dpp-node/src/infra/mod.rs b/crates/dpp-node/src/infra/mod.rs index 77459f2..468b57a 100644 --- a/crates/dpp-node/src/infra/mod.rs +++ b/crates/dpp-node/src/infra/mod.rs @@ -10,6 +10,7 @@ pub mod ruleset; pub mod s3_archive; #[cfg(feature = "s3")] pub mod s3_snapshot; +pub mod seal; pub mod seal_drain; pub mod snapshot_drain; pub mod snapshot_store; diff --git a/crates/dpp-node/src/infra/seal.rs b/crates/dpp-node/src/infra/seal.rs new file mode 100644 index 0000000..9e48c88 --- /dev/null +++ b/crates/dpp-node/src/infra/seal.rs @@ -0,0 +1,125 @@ +//! Selects the eIDAS qualified-seal backend from the environment. +//! +//! The composition root for sealing, in the same place every other adapter's +//! lives — `s3_archive`, `snapshot_store` and `credential_issuers` all resolve +//! themselves here rather than in `main`, so the binary asks for a port and does +//! not learn how any of them are built. +//! +//! # Configuration +//! +//! | Variable | Values | Meaning | +//! |-----------------|----------------------------|--------------------------------------------| +//! | `SEAL_PROVIDER` | unset / `qtsp` / `local` | Which backend to build | +//! +//! Each backend then reads its own variables — see `dpp_seal::eideasy::config` +//! and `dpp_seal::local::config`. A partial or unrecognised configuration is an +//! error rather than a silent ghost: dropping to no sealing because one variable +//! was misspelled is exactly the downgrade the trust report exists to prevent, +//! so it fails the boot. + +use std::sync::Arc; + +use anyhow::{Context, Result}; +use dpp_domain::ports::seal::{SealCredentialRef, SealPort}; +use dpp_types::trust::TrustMode; + +/// A selected seal backend, and the three facts about it the node needs. +/// +/// A struct rather than the 4-tuple this replaces: `(port, ref, mode, bool)` is +/// two fields past the point where positional returns stay readable at the call +/// site, and the last two are both "about trust" without being interchangeable. +pub struct SealWiring { + /// The port the drain seals through. + pub port: Arc, + /// Which credential the backend signs with. + /// + /// Travels from here rather than being named in the drain: this is the only + /// place that knows which backend was selected, and each backend supplies + /// its own name through the constant the selector already matches on — so + /// the two can never disagree. + pub credential: SealCredentialRef, + /// **Legal** standing — decides whether a profile will boot. + pub trust: TrustMode, + /// **Mechanical** — whether the backend emits an envelope worth draining. + /// + /// Deliberately not the same question as [`Self::trust`], and not derivable + /// from it. A locally signed seal answers yes here while answering `Ghost` + /// there. Collapsing the two either strands the local backend unexercised or + /// lets a self-signed certificate satisfy a production boot. + pub drains: bool, +} + +/// Build the seal backend named by `SEAL_PROVIDER`. +/// +/// # Errors +/// Propagates a backend's own configuration error, and an unrecognised +/// `SEAL_PROVIDER` value. Both fail the boot rather than degrading to a ghost. +pub fn from_env() -> Result { + match dpp_seal::SealProvider::from_env().context("seal provider")? { + dpp_seal::SealProvider::Qtsp => { + let cfg = + dpp_seal::eideasy::EideasyConfig::from_env().context("QTSP seal configuration")?; + // Sandbox is a real seal from a real API, but over the provider's + // test certificate — a distinct claim from both Ghost and Live. + let trust = match cfg.environment { + dpp_seal::eideasy::EideasyEnvironment::Sandbox => TrustMode::Sandbox, + dpp_seal::eideasy::EideasyEnvironment::Production => TrustMode::Live, + }; + let credential = SealCredentialRef { + qtsp_id: dpp_seal::eideasy::config::PROVIDER.to_owned(), + credential_id: cfg.client_id.clone(), + }; + tracing::info!( + base_url = %cfg.base_url, + mode = trust.as_str(), + "eIDAS seal: QTSP adapter active" + ); + let backend = dpp_seal::eideasy::EideasyClient::new(cfg) + .context("Failed to build the QTSP seal adapter")?; + Ok(SealWiring { + port: Arc::new(dpp_seal::QtspSealAdapter::new(backend)), + credential, + trust, + drains: true, + }) + } + dpp_seal::SealProvider::Local => { + let cfg = + dpp_seal::local::LocalConfig::from_env().context("local seal configuration")?; + let backend = dpp_seal::local::LocalIdentity::load_or_create(&cfg.key_path) + .context("Failed to build the local seal adapter")?; + tracing::warn!( + key_path = %cfg.key_path.display(), + "eIDAS seal: LOCAL development backend — a real CMS signature under a \ + self-signed certificate, on no EU Trusted List and of no legal weight" + ); + Ok(SealWiring { + port: Arc::new(dpp_seal::QtspSealAdapter::new(backend)), + credential: SealCredentialRef { + qtsp_id: dpp_seal::local::config::PROVIDER.to_owned(), + // No credential to reference: the key is this node's own. + credential_id: String::new(), + }, + // Ghost as a trust tier, because no authority stands behind a + // self-signed certificate — so a sandbox or production profile + // refuses to boot on it. But the envelope is real, so it drains. + trust: TrustMode::Ghost, + drains: true, + }) + } + dpp_seal::SealProvider::None => { + tracing::info!("eIDAS seal: ghost (no provider) — set SEAL_PROVIDER to enable sealing"); + Ok(SealWiring { + port: Arc::new(dpp_seal::QtspSealAdapter::new(dpp_seal::ghost::GhostSeal)), + // Never used: `drains` is false, so no drain is spawned and + // nothing builds a request from this. + credential: SealCredentialRef { + qtsp_id: String::new(), + credential_id: String::new(), + }, + trust: TrustMode::Ghost, + drains: false, + }) + } + } +} diff --git a/crates/dpp-node/src/infra/seal_drain.rs b/crates/dpp-node/src/infra/seal_drain.rs index 2f5d831..da99cbe 100644 --- a/crates/dpp-node/src/infra/seal_drain.rs +++ b/crates/dpp-node/src/infra/seal_drain.rs @@ -71,7 +71,7 @@ async fn back_off_or_exhaust( pub async fn drain_once( outbox: &Arc, seal: &Arc, - client_id: &str, + key_ref: &SealCredentialRef, batch: i64, ) -> DrainStats { let mut stats = DrainStats::default(); @@ -87,14 +87,12 @@ pub async fn drain_once( let req = SealRequest { payload_hash: row.payload_hash.clone(), mode: SealMode::ProviderSeal, - // CSC-shaped and unused by the eID Easy backend, whose credential is - // adapter config rather than a per-request reference. Filled with - // what actually identifies the sealing client so the field carries - // provenance instead of a placeholder. - key_ref: SealCredentialRef { - qtsp_id: "eideasy".to_owned(), - credential_id: client_id.to_owned(), - }, + // CSC-shaped, and no backend here reads it — each one's credential + // is adapter config rather than a per-request reference. Passed in + // from the composition root rather than named here: the drain is + // provider-agnostic, and a literal in this file would put a false + // provider name on every request of whichever backend it was not. + key_ref: key_ref.clone(), sig_format: SealFormat::Cades, }; @@ -179,6 +177,10 @@ mod tests { self.sealed.lock().unwrap().push(id); Ok(()) } + async fn sealed_digest(&self, _p: PassportId) -> Result, DppError> { + // Read back by the seal route, never by the drain. + Ok(None) + } async fn mark_attempt_failed( &self, _id: uuid::Uuid, @@ -194,6 +196,12 @@ mod tests { async fn status_counts(&self) -> Result { Ok(SealOutboxCounts::default()) } + + async fn unsealed_published_count(&self) -> Result { + // These tests drive the drain, which never asks. A passport-level + // count has no meaning against a fake holding only rows. + Ok(0) + } } struct FakeSeal { @@ -228,6 +236,16 @@ mod tests { } } + /// Stands in for whatever the composition root resolved the backend to. + /// Nothing in the drain reads it, which is exactly why it must not be named + /// here in production code either. + fn test_key_ref() -> SealCredentialRef { + SealCredentialRef { + qtsp_id: "test-provider".to_owned(), + credential_id: "test-client".to_owned(), + } + } + fn row(attempts: i32) -> SealRow { SealRow { id: uuid::Uuid::now_v7(), @@ -256,7 +274,7 @@ mod tests { let stats = drain_once( &(outbox.clone() as Arc), &(seal.clone() as Arc), - "test-client", + &test_key_ref(), 10, ) .await; @@ -275,7 +293,7 @@ mod tests { drain_once( &(outbox as Arc), &(seal.clone() as Arc), - "test-client", + &test_key_ref(), 10, ) .await; @@ -289,7 +307,7 @@ mod tests { let stats = drain_once( &(outbox.clone() as Arc), &(seal as Arc), - "test-client", + &test_key_ref(), 10, ) .await; @@ -305,7 +323,7 @@ mod tests { let stats = drain_once( &(outbox.clone() as Arc), &(seal as Arc), - "test-client", + &test_key_ref(), 10, ) .await; @@ -323,7 +341,7 @@ mod tests { let stats = drain_once( &(outbox.clone() as Arc), &(seal as Arc), - "test-client", + &test_key_ref(), 10, ) .await; diff --git a/crates/dpp-node/src/main.rs b/crates/dpp-node/src/main.rs index 165c0a5..592434f 100644 --- a/crates/dpp-node/src/main.rs +++ b/crates/dpp-node/src/main.rs @@ -19,7 +19,6 @@ use dpp_common::{ use dpp_crypto::keystore::KeyStore; use dpp_domain::ports::{ archive::ArchivePort, compliance::ComplianceRegistry, registry_sync::RegistrySyncPort, - seal::SealPort, }; use dpp_identity_service::state::AppState as IdentityState; use dpp_integrator::{infra::vault_client::VaultHttpClient, state::AppState as IntegratorState}; @@ -173,46 +172,12 @@ async fn main() -> anyhow::Result<()> { dpp_node::infra::credential_issuers::from_env(operator_did.as_deref()); let credentials_live = credential_trust != TrustMode::Ghost; - // ── eIDAS qualified seal (eID Easy Cloud Direct e-Sealing) ─────────────── - // A partial configuration is an error rather than a silent ghost: dropping - // to no sealing because one of three variables was misspelled is exactly the - // downgrade the trust report exists to prevent, so it fails the boot. - let (seal, seal_client_id, seal_trust): (Arc, String, TrustMode) = - match dpp_seal::EideasyConfig::from_env().context("eID Easy configuration")? { - Some(cfg) => { - // Sandbox is a real seal from a real API, but over eID Easy's test - // certificate — a distinct claim from both Ghost and Live. - let mode = match cfg.environment { - dpp_seal::EideasyEnvironment::Sandbox => TrustMode::Sandbox, - dpp_seal::EideasyEnvironment::Production => TrustMode::Live, - }; - let client_id = cfg.client_id.clone(); - tracing::info!( - base_url = %cfg.base_url, - mode = mode.as_str(), - "eIDAS seal: eID Easy adapter active" - ); - let adapter = dpp_seal::QtspSealAdapter::eideasy(cfg) - .context("Failed to build eID Easy seal adapter")?; - (Arc::new(adapter), client_id, mode) - } - None => { - tracing::info!( - "eIDAS seal: ghost (no QTSP) — set SEAL_PROVIDER=eideasy plus \ - SEAL_EIDEASY_BASE_URL + SEAL_EIDEASY_CLIENT_ID + SEAL_EIDEASY_HMAC_KEY \ - to enable" - ); - ( - Arc::new(dpp_seal::QtspSealAdapter::ghost()), - String::new(), - TrustMode::Ghost, - ) - } - }; - let sealing_live = seal_trust != TrustMode::Ghost; + // ── eIDAS qualified seal ───────────────────────────────────────────────── + let seal_wiring = dpp_node::infra::seal::from_env()?; + let sealing_live = seal_wiring.drains; let trust = boot::trust::build_and_enforce( - seal_trust, + seal_wiring.trust, registry_trust, archive_trust, credential_trust, @@ -423,7 +388,12 @@ async fn main() -> anyhow::Result<()> { // would stamp synthetic placeholder seals onto published passports and // report them sealed. if sealing_live { - boot::tasks::spawn_seal_drain(db.seal_outbox.clone(), seal.clone(), seal_client_id).await; + boot::tasks::spawn_seal_drain( + db.seal_outbox.clone(), + seal_wiring.port.clone(), + seal_wiring.credential, + ) + .await; // The backstop for seals the event-driven path never queued, or that gave // up during an outage. Without it a published passport can stay unsealed // forever with nothing to notice. diff --git a/crates/dpp-node/tests/seal_outbox.rs b/crates/dpp-node/tests/seal_outbox.rs index 2ffa09f..688ecb3 100644 --- a/crates/dpp-node/tests/seal_outbox.rs +++ b/crates/dpp-node/tests/seal_outbox.rs @@ -239,10 +239,10 @@ fn draft_passport() -> Passport { } } -fn eideasy_config(base_url: &str) -> dpp_seal::EideasyConfig { - dpp_seal::EideasyConfig { +fn eideasy_config(base_url: &str) -> dpp_seal::eideasy::EideasyConfig { + dpp_seal::eideasy::EideasyConfig { base_url: base_url.to_owned(), - environment: dpp_seal::EideasyEnvironment::Sandbox, + environment: dpp_seal::eideasy::EideasyEnvironment::Sandbox, client_id: MOCK_CLIENT_ID.to_owned(), hmac_key: zeroize::Zeroizing::new(MOCK_KEY.to_owned()), signature_profile: "CAdES_BASELINE_T".to_owned(), @@ -250,6 +250,24 @@ fn eideasy_config(base_url: &str) -> dpp_seal::EideasyConfig { } } +/// What the composition root resolves for this backend: the provider's own +/// selector value, plus the client the seal is billed to. No backend reads it — +/// it is carried as provenance — but it is built here rather than in the drain +/// so the drain stays provider-agnostic. +fn mock_key_ref() -> dpp_domain::ports::seal::SealCredentialRef { + dpp_domain::ports::seal::SealCredentialRef { + qtsp_id: dpp_seal::eideasy::config::PROVIDER.to_owned(), + credential_id: MOCK_CLIENT_ID.to_owned(), + } +} + +/// The real `SealPort` over the real provider backend, pointed at the mock. +fn eideasy_adapter(cfg: dpp_seal::eideasy::EideasyConfig) -> Arc { + Arc::new(QtspSealAdapter::new( + dpp_seal::eideasy::EideasyClient::new(cfg).expect("build adapter"), + )) +} + // ─── The loop ───────────────────────────────────────────────────────────────── /// Publish a real passport, let the real drain seal it against the mock, and @@ -324,10 +342,9 @@ async fn publish_then_drain_seals_the_passport_end_to_end() { ); // ── 3. Drain: the real adapter against the mock ────────────────────────── - let adapter: Arc = - Arc::new(QtspSealAdapter::eideasy(eideasy_config(&base_url)).expect("build adapter")); + let adapter = eideasy_adapter(eideasy_config(&base_url)); let outbox_dyn: Arc = seal_outbox.clone(); - let stats = drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + let stats = drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; assert_eq!(stats.sealed, 1, "the drain must seal the queued row"); assert_eq!(stats.retried, 0); @@ -397,7 +414,7 @@ async fn publish_then_drain_seals_the_passport_end_to_end() { assert_eq!(counts.sealed, 1); assert_eq!(counts.pending, 0); - let stats2 = drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + let stats2 = drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; assert_eq!(stats2.sealed, 0, "a closed row must not be re-sealed"); assert_eq!( mock.requests.lock().unwrap().len(), @@ -449,10 +466,9 @@ async fn a_republish_needs_and_gets_its_own_seal() { let first = service.publish(id, &auth()).await.expect("publish"); let first_jws = first.jws_signature.clone().unwrap(); - let adapter: Arc = - Arc::new(QtspSealAdapter::eideasy(eideasy_config(&base_url)).expect("build adapter")); + let adapter = eideasy_adapter(eideasy_config(&base_url)); let outbox_dyn: Arc = seal_outbox.clone(); - drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; // Suspend → publish again: the signing path runs afresh. service @@ -478,7 +494,7 @@ async fn a_republish_needs_and_gets_its_own_seal() { hex::encode(Sha256::digest(second_jws.as_bytes())) ); - drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; let counts = seal_outbox.status_counts().await.expect("counts"); assert_eq!(counts.sealed, 2, "one seal per distinct signature"); assert_eq!( @@ -527,11 +543,10 @@ async fn a_wrong_key_is_rejected_and_the_row_stays_pending() { let mut bad = eideasy_config(&base_url); bad.hmac_key = zeroize::Zeroizing::new("the-wrong-key".to_owned()); - let adapter: Arc = - Arc::new(QtspSealAdapter::eideasy(bad).expect("build adapter")); + let adapter = eideasy_adapter(bad); let outbox_dyn: Arc = seal_outbox.clone(); - let stats = drain_once(&outbox_dyn, &adapter, MOCK_CLIENT_ID, 10).await; + let stats = drain_once(&outbox_dyn, &adapter, &mock_key_ref(), 10).await; assert_eq!(stats.sealed, 0); assert_eq!(stats.retried, 1, "a rejected call must back off, not drop"); assert_eq!(*mock.rejected.lock().unwrap(), 1); diff --git a/crates/dpp-render/dpp-render-preview-live.html b/crates/dpp-render/dpp-render-preview-live.html new file mode 100644 index 0000000..8b30a2f --- /dev/null +++ b/crates/dpp-render/dpp-render-preview-live.html @@ -0,0 +1,84 @@ + + + + + + + + + + DPP — Organic Cotton T-Shirt + + + +
+ +

Organic Cotton T-Shirt

+ active + +

Product Information

+ + + + + +
Passport ID0190a9f0-1234-7abc-8def-0123456789ab
ManufacturerSample Textiles Co.
GTIN09506000134352
Batch ID-
+ +

Textile Information

+ + + + + + + + + +
Country of ManufacturingGermany
Care InstructionsMachine wash cold, tumble dry low
Chemical ComplianceOEKO-TEX Standard 100
Recycled Content32.5%
Fibre Composition + +
Organic Cotton 80.0%Recycled Polyester 15.0%Elastane 5.0%
+
+ +
+ + QR code: https://id.odal-node.io/01/09506000134352/21/7abc8def0123456789ab + + + +

Scan to verify

+
+ + +
+ + \ No newline at end of file diff --git a/crates/dpp-render/dpp-render-preview-snapshot.html b/crates/dpp-render/dpp-render-preview-snapshot.html new file mode 100644 index 0000000..196815d --- /dev/null +++ b/crates/dpp-render/dpp-render-preview-snapshot.html @@ -0,0 +1,84 @@ + + + + + + + + + + DPP — Organic Cotton T-Shirt + + + +
+
This is a saved copy of this passport as of 2026-08-13 21:56 UTC. The live service is temporarily unavailable, so some details may have changed since. The copy is still signed and can be verified.
+

Organic Cotton T-Shirt

+ active + +

Product Information

+ + + + + +
Passport ID0190a9f0-1234-7abc-8def-0123456789ab
ManufacturerSample Textiles Co.
GTIN09506000134352
Batch ID-
+ +

Textile Information

+ + + + + + + + + +
Country of ManufacturingGermany
Care InstructionsMachine wash cold, tumble dry low
Chemical ComplianceOEKO-TEX Standard 100
Recycled Content32.5%
Fibre Composition + +
Organic Cotton 80.0%Recycled Polyester 15.0%Elastane 5.0%
+
+ +
+ + QR code: https://id.odal-node.io/01/09506000134352/21/7abc8def0123456789ab + + + +

Scan to verify

+
+ + +
+ + \ No newline at end of file diff --git a/crates/dpp-seal/Cargo.toml b/crates/dpp-seal/Cargo.toml index 7e2aef3..8c2724a 100644 --- a/crates/dpp-seal/Cargo.toml +++ b/crates/dpp-seal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dpp-seal" -description = "eIDAS qualified seal adapter — eID Easy Cloud Direct e-Sealing backend for Odal Node" +description = "eIDAS qualified seal adapter for Odal Node — a hosted QTSP, a local dev sealer, or a ghost" version.workspace = true edition.workspace = true authors.workspace = true @@ -28,6 +28,21 @@ hex = { workspace = true } url = { workspace = true } zeroize = { workspace = true } +# Local development backend: a real detached CMS SignedData over the digest, +# signed by a self-signed key. Not qualified — the certificate is on no Trusted +# List — but structurally the shape a provider returns, so the whole pipeline is +# exercised without a provider account. +# `default-features = false` keeps `rsa` out of the tree: the `builder` feature +# pulls it in, we sign with P-256, and `rsa` 0.9 carries RUSTSEC-2023-0071 (Marvin +# timing sidechannel) with no fixed upgrade available. +cms = { version = "0.2", default-features = false } +x509-cert = "0.2" +der = "0.7" +const-oid = "0.9" +p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] } +rcgen = "0.14" + [dev-dependencies] +tempfile = "3" tokio = { workspace = true } axum = { workspace = true } diff --git a/crates/dpp-seal/README.md b/crates/dpp-seal/README.md index ddf694c..a3f7419 100644 --- a/crates/dpp-seal/README.md +++ b/crates/dpp-seal/README.md @@ -2,10 +2,11 @@ eIDAS qualified electronic seal adapter for [Odal Node](https://odal-node.io). -Implements `SealPort` (from `dpp-domain::ports::seal`) against **eID Easy Cloud -Direct e-Sealing**, which aggregates qualified QTSPs and returns **CAdES**. When -eID Easy is not configured the adapter delegates to `GhostSeal`, and a production -node refuses to boot. +Implements `SealPort` (from `dpp-domain::ports::seal`) over one of three +backends, selected by `SEAL_PROVIDER`: **eID Easy Cloud Direct e-Sealing**, which +aggregates qualified QTSPs and returns **CAdES**; a **local** development sealer; +or `GhostSeal` when nothing is configured. Only the first carries legal weight, +and a production node refuses to boot on either of the others. > **Not yet exercised against the live provider.** Every code path is built and > tested against a local mock; the sandbox test is blocked on eID Easy enabling @@ -15,16 +16,38 @@ node refuses to boot. | Component | Status | |---|---| -| `QtspSealAdapter` | eID Easy backend, or `GhostSeal` when unconfigured | -| `config::EideasyConfig` | `from_env()`, host allowlist, redacting `Debug` | -| `eideasy::client` | HMAC-signed POST to `/api/signatures/e-seal` | -| `eideasy::types` | Request/response wire types | -| `error::SealError` | Typed auth / transport / protocol / provider failures | - -`verify()` is **not** implemented and says so, returning a typed error. Verifying -a detached CAdES needs an independent AdES validator — no Rust implementation -exists — and a seal is worth exactly as much as the independence of whoever -checked it, so this adapter never reports a verdict it did not compute. +| `backend::SealBackend` | The seam each backend implements | +| `QtspSealAdapter` | `SealPort` over one `dyn SealBackend`; names none of them | +| `config::SealProvider` | Which backend this node runs, resolved from the environment | +| `eideasy::` | The hosted QTSP backend: config (host allowlist, redacting `Debug`), HMAC-signed POST to `/api/signatures/e-seal`, wire types, and its own typed errors | +| `local::` | A real detached CMS `SignedData` under a self-signed P-256 key | +| `ghost::` | `GhostSeal` as a backend — synthetic envelopes, no legal validity | +| `error::SealError` | Config / transport / backend / unsupported — nothing provider-shaped | + +Each backend owns its module entirely: its variables, its validation, its failure +messages, its wire types, and its own construction. Nothing outside a backend's +module names it, so one can be added or dropped without touching the others. + +`verify()` is **not** implemented for the hosted backend and says so, returning a +typed error. It is the `SealBackend` default, so a new backend refuses by +construction and has to override it to claim otherwise. + +The reason is independence, not tooling. A qualified seal is worth exactly as +much as the independence of whoever checked it, so a verdict this node issues on +a seal this node bought attests nothing a relying party should accept — validate +those elsewhere. Rust AdES libraries exist and are improving; none of that +changes the answer. + +**The local backend does verify**, and overrides the default to say so, because +neither objection applies to it: its seals make no trust claim beyond "this key +signed this digest", so a cryptographic check is the whole truth about them and +there is no authority whose independence could be borrowed. It carries the sealed +digest in CMS `signedAttrs` — as CAdES requires — which is what makes the +envelope self-checking; a verifier holding only the bytes can confirm the +signature over those attributes against the certificate travelling inside. That +establishes internal consistency and nothing about trust: the certificate is +self-signed and on no EU Trusted List, which the node states structurally by +resolving this backend to the `Ghost` trust tier. ## The one rule @@ -81,17 +104,19 @@ unused, for if they confirm the accurate value is accepted. ## Configuration ``` -SEAL_PROVIDER=eideasy # or `none` (default) for GhostSeal +SEAL_PROVIDER=eideasy # `local`, or `none` (default) for GhostSeal SEAL_EIDEASY_BASE_URL=https://test.eideasy.com # sandbox; prod = https://id.eideasy.com SEAL_EIDEASY_CLIENT_ID=... # from test.eideasy.com "My Webpages" SEAL_EIDEASY_HMAC_KEY=... # generated once in Eseal Settings SEAL_EIDEASY_SIGNATURE_PROFILE=CAdES_BASELINE_T # optional; must be enabled for client + +SEAL_LOCAL_KEY_PATH=./.seal-local # optional; only for SEAL_PROVIDER=local ``` -`SEAL_PROVIDER` takes `eideasy` or `none`, and an unrecognised value fails the -boot. It exists with one provider because env var names are a published -interface: adding a QTSP later must not force every self-hoster through a config -migration. +`SEAL_PROVIDER` takes `eideasy`, `local` or `none`, and an unrecognised value +fails the boot. The selection is deliberately explicit rather than inferred from +whichever credentials happen to be present: a node that cannot name its trust +provider must not quietly become one that has none. Three failure modes are all refused rather than ghosted, for the same reason — each would downgrade a node that was configured for qualified sealing into one @@ -105,8 +130,9 @@ in the node `.env` (mode 600) beside store, which holds Ed25519 pairs for DID publication, and not in Postgres, which would put a live signing credential in every backup. -When eID Easy config is absent, `QtspSealAdapter` falls back to `GhostSeal` and -logs a warning; `NODE_PROFILE=production` refuses to boot. +With no provider selected the node wires the ghost backend, which logs a warning +on every seal; `NODE_PROFILE=production` refuses to boot on it — and on the local +backend too, whose certificate is on no EU Trusted List. ## License diff --git a/crates/dpp-seal/src/adapter.rs b/crates/dpp-seal/src/adapter.rs index affd39e..a06eebb 100644 --- a/crates/dpp-seal/src/adapter.rs +++ b/crates/dpp-seal/src/adapter.rs @@ -1,62 +1,29 @@ //! `QtspSealAdapter` — the `SealPort` implementation. //! -//! Two states, and no third: an eID Easy backend, or `GhostSeal`. The previous -//! "configured but unimplemented" tier is gone — it was a state that had to -//! report empty capabilities so it would not contradict a `seal()` that always -//! errored. +//! It holds one [`SealBackend`] and does nothing else: forward the port's three +//! calls, and collapse [`SealError`] into `DppError` at the boundary. Which +//! backend it holds is decided by [`crate::config`] and constructed by that +//! backend's own module, so nothing in this file names one — adding or removing +//! a backend leaves it untouched. use async_trait::async_trait; -use chrono::Utc; use dpp_domain::{ domain::error::DppError, - ports::seal::{ - GhostSeal, SealCapabilities, SealFormat, SealMode, SealPort, SealRequest, SealVerification, - SealedEnvelope, - }, + ports::seal::{SealCapabilities, SealPort, SealRequest, SealVerification, SealedEnvelope}, }; -use tracing::warn; -use crate::config::EideasyConfig; -use crate::eideasy::client::EideasyClient; -use crate::error::SealError; +use crate::backend::SealBackend; -/// What `verify()` says instead of guessing. -const VERIFY_UNSUPPORTED: &str = "seal verification is not implemented: a detached CAdES must be validated by an independent \ - AdES validator (no Rust implementation exists), and this adapter will not report a seal \ - valid on a check it did not perform"; - -/// eIDAS seal adapter. +/// eIDAS seal adapter over whichever backend the node was configured with. pub struct QtspSealAdapter { - backend: Backend, -} - -enum Backend { - /// Live eID Easy Cloud Direct e-Sealing. - Eideasy(Box), - /// Placeholder with no legal validity; a production node refuses to boot on it. - Ghost, + backend: Box, } impl QtspSealAdapter { - /// Adapter backed by eID Easy. - pub fn eideasy(config: EideasyConfig) -> Result { - Ok(Self { - backend: Backend::Eideasy(Box::new(EideasyClient::new(config)?)), - }) - } - - /// Placeholder adapter — synthetic envelopes, no legal validity. - pub fn ghost() -> Self { + /// Wrap a backend as the node's `SealPort`. + pub fn new(backend: impl SealBackend + 'static) -> Self { Self { - backend: Backend::Ghost, - } - } - - /// The configured backend, when there is one. `None` means ghost-backed. - pub fn config(&self) -> Option<&EideasyConfig> { - match &self.backend { - Backend::Eideasy(c) => Some(c.config()), - Backend::Ghost => None, + backend: Box::new(backend), } } } @@ -64,78 +31,63 @@ impl QtspSealAdapter { #[async_trait] impl SealPort for QtspSealAdapter { async fn seal(&self, req: SealRequest) -> Result { - let client = match &self.backend { - Backend::Ghost => { - warn!("eID Easy not configured — using GhostSeal (placeholder, no legal validity)"); - return GhostSeal.seal(req).await; - } - Backend::Eideasy(c) => c, - }; - - // The digest doubles as the correlation label: eID Easy echoes `fileName` - // back, so deriving it from the digest makes a mismatched response visible - // without holding request state. No extension — see `EsealFile::file_name`. - let file_name = format!( - "dpp-{}", - &req.payload_hash[..req.payload_hash.len().min(16)] - ); - let seal_value = client - .seal_digest(&file_name, &req.payload_hash) - .await - .map_err(DppError::from)?; - - Ok(SealedEnvelope { - format: SealFormat::Cades, - seal_value, - // The signing certificate travels *inside* the detached CAdES, and - // reading it out needs the CMS parser this adapter deliberately does - // not have. Left `None` rather than filled with a guess. - signing_cert_ref: None, - sealed_at: Utc::now(), - placeholder: false, - }) + self.backend.seal(req).await.map_err(DppError::from) } async fn verify(&self, env: &SealedEnvelope) -> Result { - match &self.backend { - Backend::Ghost => GhostSeal.verify(env).await, - Backend::Eideasy(_) => Err(SealError::Unsupported(VERIFY_UNSUPPORTED).into()), - } + self.backend.verify(env).await.map_err(DppError::from) } fn capabilities(&self) -> SealCapabilities { - match &self.backend { - // The placeholder path genuinely produces (synthetic) seals — report - // what `GhostSeal` actually does. - Backend::Ghost => GhostSeal.capabilities(), - // eID Easy Direct e-Sealing offers the CAdES profile only, and this - // node holds the seal on operators' behalf, so `ProviderSeal` is the - // one mode. `verify()` is unsupported and no capability claims it. - Backend::Eideasy(_) => SealCapabilities { - supported_formats: vec![SealFormat::Cades], - supported_modes: vec![SealMode::ProviderSeal], - }, - } + self.backend.capabilities() } } #[cfg(test)] mod tests { use super::*; - use crate::config::{SANDBOX_BASE_URL, test_config}; + use crate::ghost::GhostSeal; #[test] fn unconfigured_reports_ghost_capabilities() { - let caps = QtspSealAdapter::ghost().capabilities(); + let caps = QtspSealAdapter::new(GhostSeal).capabilities(); assert!(!caps.supported_formats.is_empty()); } - #[test] - fn the_eideasy_backend_advertises_cades_only() { - let caps = QtspSealAdapter::eideasy(test_config(SANDBOX_BASE_URL)) - .unwrap() - .capabilities(); - assert_eq!(caps.supported_formats, vec![SealFormat::Cades]); - assert_eq!(caps.supported_modes, vec![SealMode::ProviderSeal]); + /// A backend that implements only what the trait requires must not verify. + /// + /// The default is what stops a new backend from silently inheriting a + /// "valid" answer it never computed — so it is checked through the adapter, + /// where the refusal has to survive the conversion to `DppError` to reach a + /// caller at all. + #[tokio::test] + async fn a_backend_that_says_nothing_about_verify_refuses() { + struct Minimal; + + #[async_trait] + impl SealBackend for Minimal { + async fn seal(&self, _req: SealRequest) -> Result { + unreachable!("this test never seals") + } + fn capabilities(&self) -> SealCapabilities { + SealCapabilities { + supported_formats: Vec::new(), + supported_modes: Vec::new(), + } + } + } + + let env = SealedEnvelope { + format: dpp_domain::ports::seal::SealFormat::Cades, + seal_value: "p7s".into(), + signing_cert_ref: None, + sealed_at: chrono::Utc::now(), + placeholder: false, + }; + let err = QtspSealAdapter::new(Minimal) + .verify(&env) + .await + .expect_err("the default must refuse rather than answer"); + assert!(err.to_string().contains("not implemented"), "{err}"); } } diff --git a/crates/dpp-seal/src/backend.rs b/crates/dpp-seal/src/backend.rs new file mode 100644 index 0000000..50464d0 --- /dev/null +++ b/crates/dpp-seal/src/backend.rs @@ -0,0 +1,57 @@ +//! `SealBackend` — the one thing every sealing backend has to be able to do. +//! +//! [`crate::adapter::QtspSealAdapter`] holds one of these and does nothing but +//! forward the port's calls to it. The seam exists so that adding, swapping or +//! dropping a backend touches that backend's module and the selector, and +//! nothing else — in particular, not the `SealPort` implementation, which has +//! no business knowing which one it holds. +//! +//! Two things separate this from `SealPort` itself, and they are the reason it +//! is a distinct trait rather than a rename: +//! +//! - **The error type.** A backend returns [`SealError`], which still carries +//! the classification made where the failure happened. The adapter collapses +//! it into `DppError` once, at the port boundary, so no backend has to. +//! - **`verify` refuses by default.** Claiming a seal valid on a check that was +//! never performed is the failure this crate is most exposed to, so silence is +//! the default and a backend that can genuinely verify has to say so. + +use async_trait::async_trait; +use dpp_domain::ports::seal::{SealCapabilities, SealRequest, SealVerification, SealedEnvelope}; + +use crate::error::SealError; + +/// What [`SealBackend::verify`] says instead of guessing. +const VERIFY_UNSUPPORTED: &str = "seal verification is not implemented for this backend: a qualified seal is only meaningfully \ + validated by a party independent of whoever produced it, and this adapter will not report a \ + seal valid on a check it did not perform"; + +/// One way of producing a seal: a hosted trust service, a local key, a placeholder. +#[async_trait] +pub trait SealBackend: Send + Sync { + /// Produce a seal over the request's payload digest. + async fn seal(&self, req: SealRequest) -> Result; + + /// Which formats and modes this backend can actually produce. + /// + /// It must answer for what it does, not for what the port allows: a + /// capability nothing here can deliver is a promise the trust report will + /// carry outward. + fn capabilities(&self) -> SealCapabilities; + + /// Verify a seal — by default, refuse to. + /// + /// The default is refusal because the interesting case cannot be answered + /// here: a *qualified* seal is worth what the independence of its validator + /// is worth, so a verdict this node issues on a seal this node bought + /// attests nothing a relying party should accept. Rust AdES tooling exists + /// and will improve; that was never what made the answer unavailable. + /// + /// A backend that can genuinely check its own output — one whose seals make + /// no trust claim beyond the key, so that a cryptographic check *is* the + /// whole truth about them — should override this and say so. + async fn verify(&self, env: &SealedEnvelope) -> Result { + let _ = env; + Err(SealError::Unsupported(VERIFY_UNSUPPORTED)) + } +} diff --git a/crates/dpp-seal/src/cades.rs b/crates/dpp-seal/src/cades.rs new file mode 100644 index 0000000..9af2ed7 --- /dev/null +++ b/crates/dpp-seal/src/cades.rs @@ -0,0 +1,211 @@ +//! Reading what a detached CAdES says about itself. +//! +//! Shared by the backends because the parsing is plain CMS and belongs to +//! neither: a backend module may not reach into another's, and duplicating +//! ASN.1 handling across two of them is how the two quietly stop agreeing about +//! what a seal contains. +//! +//! # Everything here is *reported*, never *verified* +//! +//! This module reads a structure. It builds no certificate chain, contacts no +//! Trusted List, and checks no revocation — so a certificate it names is the one +//! the seal **claims** signed it, on the seal's own word. For a qualified seal +//! that claim is worth checking and this module cannot check it; establishing +//! that the certificate was qualified, and current, at the moment of sealing is +//! an independent AdES validator's job. +//! +//! The distinction is the whole reason this is a separate module with its own +//! vocabulary. A convenience field that reads as verification while verifying +//! nothing is worse than an absent one, because an absent field prompts the +//! question and a populated one settles it wrongly. + +use cms::cert::CertificateChoices; +use cms::content_info::ContentInfo; +use cms::signed_data::{SignedData, SignerInfo}; +use der::{Decode as _, Encode as _}; +use x509_cert::Certificate; + +use crate::error::SealError; + +fn malformed(what: impl std::fmt::Display) -> SealError { + SealError::Backend(format!("cannot read the seal: {what}")) +} + +/// The one signer and the certificate it travels with. +struct Signed { + signer: SignerInfo, + certificate: Certificate, +} + +/// Parse a detached CMS `SignedData` down to its single signer and certificate. +/// +/// One signer is not a simplification: this crate sends one digest per request +/// and a response bearing more than one signature does not answer the request +/// that was made. Reaching into `[0]` and hoping would turn that into a silent +/// mismatch. +fn parse(seal_der: &[u8]) -> Result { + let info = ContentInfo::from_der(seal_der).map_err(|e| malformed(format!("not CMS: {e}")))?; + let sd: SignedData = info + .content + .decode_as() + .map_err(|e| malformed(format!("not SignedData: {e}")))?; + + let signers = sd.signer_infos.0.as_slice(); + let [signer] = signers else { + return Err(malformed(format!( + "expected exactly one signer, found {}", + signers.len() + ))); + }; + + let certs = sd + .certificates + .as_ref() + .ok_or_else(|| malformed("it carries no certificate"))?; + let Some(CertificateChoices::Certificate(certificate)) = certs.0.as_slice().first() else { + return Err(malformed("it carries no X.509 certificate")); + }; + + Ok(Signed { + signer: signer.clone(), + certificate: certificate.clone(), + }) +} + +/// Hex SHA-256 over the DER of the certificate the seal carries. +/// +/// **Reported by the seal, not verified.** This identifies *which* certificate +/// the seal names, so an auditor can ask whether it was on the EU Trusted List +/// at the sealing time without first being handed the `.p7s` and parsing it. +/// Answering that question is the validator's job, not this function's. +/// +/// A thumbprint rather than issuer+serial or a subject key identifier: it is one +/// fixed-length value that names exactly one certificate, needs no parsing to +/// compare, and is what the local backend already reports — so the two backends +/// put the same kind of thing in the same field. +/// +/// `Ok(None)` when the bytes are not a seal this module can read. A seal that +/// arrived and stored fine must not be lost to a parse failure on a convenience +/// field, so the caller degrades to an unpopulated reference rather than failing +/// the seal. +pub fn signer_certificate_thumbprint(seal_der: &[u8]) -> Result, SealError> { + use sha2::{Digest as _, Sha256}; + + let Ok(signed) = parse(seal_der) else { + return Ok(None); + }; + let der = signed + .certificate + .to_der() + .map_err(|e| malformed(format!("cannot re-encode the certificate: {e}")))?; + Ok(Some(hex::encode(Sha256::digest(&der)))) +} + +/// Check the signature against the certificate the seal carries. +/// +/// A `true` means the signature over the signed attributes verifies under the +/// public key in the certificate travelling inside the seal — the structure is +/// internally consistent. It says **nothing** about trust: no chain was built and +/// no authority was consulted. Whether that is the whole truth about a seal or +/// only a fragment of it depends on the certificate, which is why the decision to +/// report it as a verdict belongs to the backend rather than here. +/// +/// Only P-256 is understood, which is what this crate's local backend produces. +pub fn verify_against_embedded_certificate(seal_der: &[u8]) -> Result { + use p256::ecdsa::VerifyingKey; + use p256::ecdsa::signature::Verifier as _; + + let signed = parse(seal_der)?; + + // Absent signed attributes means the digest is not inside the seal, so + // nothing can be checked without the original payload — which this function + // is not given. That is a different answer from "invalid", and conflating the + // two would brand a seal broken on the strength of a check that never ran. + let Some(signed_attrs) = signed.signer.signed_attrs.as_ref() else { + return Err(SealError::Backend( + "this seal carries no signed attributes, so the digest it covers is not inside it \ + and cannot be checked from the envelope alone" + .to_owned(), + )); + }; + + let spki = &signed.certificate.tbs_certificate.subject_public_key_info; + let key_bits = spki + .subject_public_key + .as_bytes() + .ok_or_else(|| malformed("the certificate's public key is not whole bytes"))?; + let vk = VerifyingKey::from_sec1_bytes(key_bits) + .map_err(|e| malformed(format!("the certificate holds no P-256 key: {e}")))?; + + // Re-encode as SET OF, matching what was signed (RFC 5652 §5.4). + let to_verify = signed_attrs + .to_der() + .map_err(|e| malformed(format!("cannot re-encode the signed attributes: {e}")))?; + let sig = p256::ecdsa::DerSignature::from_bytes(signed.signer.signature.as_bytes()) + .map_err(|e| malformed(format!("not a DER ECDSA signature: {e}")))?; + + Ok(vk.verify(&to_verify, &sig).is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Unreadable bytes yield no reference rather than an error. + /// + /// The seal itself is fine — it was produced, paid for, and stored. Losing it + /// because a convenience field could not be filled would trade something that + /// matters for something that does not. + #[test] + fn unreadable_bytes_yield_no_thumbprint() { + assert_eq!( + signer_certificate_thumbprint(b"not a CMS structure at all").unwrap(), + None + ); + assert_eq!(signer_certificate_thumbprint(&[]).unwrap(), None); + } + + /// Reading the certificate out of a seal agrees with the identity that + /// signed it. + /// + /// This is the claim that makes `signing_cert_ref` comparable across + /// backends. One backend knows its own certificate and reports it directly; + /// the other can only read it back out of the bytes a provider returned. If + /// those two ever produced different values for the same certificate, the + /// field would silently stop being a key an auditor can match on. + /// + /// The local backend supplies realistic bytes here because it is the only + /// source of a genuine CMS structure in this crate — a hand-rolled fixture + /// would prove that the parser agrees with the fixture, not with reality. + #[test] + fn the_thumbprint_matches_the_identity_that_signed() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let seal = id.sign_detached(&[0x11; 32]).expect("sign"); + + assert_eq!( + signer_certificate_thumbprint(&seal).unwrap(), + Some(id.cert_thumbprint()), + "the certificate read out of a seal must be the one that signed it" + ); + } + + /// A seal signed by a different identity reports a different certificate. + /// + /// Without this, the function could return a constant and the test above + /// would still pass. + #[test] + fn a_different_signer_yields_a_different_thumbprint() { + let make = || { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let seal = id.sign_detached(&[0x11; 32]).expect("sign"); + (signer_certificate_thumbprint(&seal).unwrap(), dir) + }; + + let (a, _da) = make(); + let (b, _db) = make(); + assert!(a.is_some() && b.is_some()); + assert_ne!(a, b, "two certificates must not report the same thumbprint"); + } +} diff --git a/crates/dpp-seal/src/config.rs b/crates/dpp-seal/src/config.rs index 164dd29..33ef405 100644 --- a/crates/dpp-seal/src/config.rs +++ b/crates/dpp-seal/src/config.rs @@ -1,337 +1,152 @@ -//! Configuration for the eID Easy Cloud Direct e-Sealing backend. +//! Which sealing backend this node runs, resolved from the environment. //! -//! The HMAC key is an operator-supplied, node-wide credential for one outbound -//! service, needed at boot and never created at runtime — the same category as -//! `EU_REGISTRY_CLIENT_SECRET` and `MTLS_PROXY_SHARED_SECRET`, and delivered the -//! same way (the node's `.env`, mode 600). It is deliberately not -//! in the key store, which holds Ed25519 key pairs for DID publication, and not -//! in Postgres, which would put a live signing credential in every backup. - -use std::time::Duration; - -use zeroize::Zeroizing; +//! This module knows the *selection* rule and nothing about any particular +//! backend. Each backend owns its own variables, its own validation and its own +//! failure messages, inside its own module — so adding one is additive here and +//! removing one leaves nothing behind. use crate::error::SealError; -/// Which eID Easy deployment the configured base URL points at. +/// Selects the sealing backend. Unset means no seal provider. /// -/// Derived from the host rather than configured separately, so the environment -/// and the URL cannot disagree. This is what the node maps to its trust tier — -/// a sandbox seal is a real seal from a real API, but it carries eID Easy's test -/// certificate and has no legal validity, which is a different claim from Ghost. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EideasyEnvironment { - /// `test.eideasy.com` — developer credentials, eID Easy test certificates. - Sandbox, - /// `id.eideasy.com` — production, bound to the qualified certificate. - Production, -} - -pub const SANDBOX_BASE_URL: &str = "https://test.eideasy.com"; -pub const PRODUCTION_BASE_URL: &str = "https://id.eideasy.com"; +/// Deliberately explicit rather than inferred from whichever credentials happen +/// to be present: a node that cannot name its trust provider must not quietly +/// become one that has none. +pub const SEAL_PROVIDER: &str = "SEAL_PROVIDER"; -const SANDBOX_HOST: &str = "test.eideasy.com"; -const PRODUCTION_HOST: &str = "id.eideasy.com"; +const PROVIDER_NONE: &str = "none"; -/// Resolved eID Easy configuration. +/// The backend a node is configured to seal with. /// -/// `Debug` is implemented by hand: the derived one would print `hmac_key` in -/// full through any `{:?}` — a `tracing` field, an `anyhow` context, a panic -/// message. -#[derive(Clone)] -pub struct EideasyConfig { - /// Origin only, no trailing slash. The path is [`crate::eideasy::client::ESEAL_PATH`]. - pub base_url: String, - /// Which deployment `base_url` names. - pub environment: EideasyEnvironment, - /// From eID Easy "My Webpages". Not a secret — it travels in the request body. - pub client_id: String, - /// The e-seal HMAC key, shown once at generation. - pub hmac_key: Zeroizing, - /// Must be a profile eID Easy has enabled for `client_id`. - pub signature_profile: String, - pub request_timeout: Duration, -} - -impl std::fmt::Debug for EideasyConfig { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EideasyConfig") - .field("base_url", &self.base_url) - .field("environment", &self.environment) - .field("client_id", &self.client_id) - .field("hmac_key", &"[redacted]") - .field("signature_profile", &self.signature_profile) - .field("request_timeout", &self.request_timeout) - .finish() - } +/// Deliberately **not** `#[non_exhaustive]`: adding a backend should break every +/// wiring site until it is handled. A `_` arm here is a node that silently seals +/// with something other than what it was asked for, which is the downgrade the +/// trust report exists to prevent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SealProvider { + /// The hosted QTSP backend. + Qtsp, + /// In-process signing with a locally generated key. Development only — the + /// certificate is not on an EU Trusted List, so the envelope is structurally + /// a seal and legally nothing. + Local, + /// No provider. The node wires `GhostSeal`, which a development profile + /// permits and a production profile refuses at boot. + None, } -/// Selects which QTSP backend the node seals with. -/// -/// `eideasy` is the only value today. It exists as a selector anyway because env -/// var names are a published interface: adding a provider later must not force -/// every self-hoster through a config migration. An unrecognised value is a boot -/// error, not a fallback — a node that cannot name its trust provider must not -/// quietly become one that has none. -pub const SEAL_PROVIDER: &str = "SEAL_PROVIDER"; - -const PROVIDER_EIDEASY: &str = "eideasy"; -const PROVIDER_NONE: &str = "none"; - -impl EideasyConfig { - /// Read configuration from the environment. - /// - /// Returns `Ok(None)` when no provider is selected — the node then wires - /// `GhostSeal`, which a development profile permits and a production profile - /// refuses at boot. - /// - /// A *partial* configuration is an error, not a `None`. Silently falling back - /// to a ghost because one of three variables was misspelled would downgrade a - /// node from qualified sealing to no sealing on a typo, which is precisely the - /// failure the trust report exists to make impossible. Setting the - /// `SEAL_EIDEASY_*` variables without selecting the provider is the same class - /// of mistake and is refused for the same reason. - /// - /// When a second backend lands this becomes a dispatch on - /// [`SEAL_PROVIDER`]; the shape is here so that change is additive. - pub fn from_env() -> Result, SealError> { +impl SealProvider { + /// Read the selection from the process environment. + pub fn from_env() -> Result { Self::resolve(|name| std::env::var(name).ok()) } - /// The whole of [`Self::from_env`]'s logic, over an arbitrary lookup. + /// The whole of [`Self::from_env`]'s logic over an arbitrary lookup. /// - /// Split out so the rules above — provider selection, all-or-nothing - /// credentials, host allowlisting — are testable as a pure function. - /// Exercising them through the real environment would mean mutating - /// process-global state from tests, which is `unsafe` under Rust 2024, forces - /// them to serialise against one another, and lets a stray variable in the - /// developer's shell change the result. - pub fn resolve(get: impl Fn(&str) -> Option) -> Result, SealError> { - let read = |name: &str| { - get(name) - .map(|v| v.trim().to_owned()) - .filter(|v| !v.is_empty()) - }; - - let base_url = read("SEAL_EIDEASY_BASE_URL"); - let client_id = read("SEAL_EIDEASY_CLIENT_ID"); - let hmac_key = read("SEAL_EIDEASY_HMAC_KEY"); - let any_eideasy_var = base_url.is_some() || client_id.is_some() || hmac_key.is_some(); - - match read(SEAL_PROVIDER).as_deref() { + /// Split out so the rule is testable as a pure function. Exercising it + /// through the real environment would mean mutating process-global state + /// from tests, which is `unsafe` under Rust 2024, forces them to serialise + /// against one another, and lets a stray variable in the developer's shell + /// change the result. + pub fn resolve(get: impl Fn(&str) -> Option) -> Result { + let selected = get(SEAL_PROVIDER) + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()); + + match selected.as_deref() { None | Some(PROVIDER_NONE) => { - if any_eideasy_var { + // A backend's credentials present without its provider selected + // is a misconfiguration, not a fallback: silently ghosting on a + // misspelled variable would downgrade a node from qualified + // sealing to no sealing, which is exactly what the trust report + // exists to make impossible. + if let Some(stray) = stray_backend_vars(&get) { return Err(SealError::Config(format!( - "SEAL_EIDEASY_* is set but {SEAL_PROVIDER} is not `{PROVIDER_EIDEASY}` — \ - set it, or unset the SEAL_EIDEASY_* variables to run with GhostSeal" + "{stray} is set but {SEAL_PROVIDER} is not — set it, or unset those \ + variables to run with GhostSeal" ))); } - return Ok(None); - } - Some(PROVIDER_EIDEASY) => {} - Some(other) => { - return Err(SealError::Config(format!( - "unknown {SEAL_PROVIDER} `{other}` — supported: `{PROVIDER_EIDEASY}`, \ - `{PROVIDER_NONE}`" - ))); + Ok(Self::None) } + Some(crate::eideasy::config::PROVIDER) => Ok(Self::Qtsp), + Some(crate::local::config::PROVIDER) => Ok(Self::Local), + Some(other) => Err(SealError::Config(format!( + "unknown {SEAL_PROVIDER} `{other}` — supported: `{}`, `{}`, `{PROVIDER_NONE}`", + crate::eideasy::config::PROVIDER, + crate::local::config::PROVIDER + ))), } - - let missing: Vec<&str> = [ - ("SEAL_EIDEASY_BASE_URL", &base_url), - ("SEAL_EIDEASY_CLIENT_ID", &client_id), - ("SEAL_EIDEASY_HMAC_KEY", &hmac_key), - ] - .iter() - .filter(|(_, v)| v.is_none()) - .map(|(n, _)| *n) - .collect(); - if !missing.is_empty() { - return Err(SealError::Config(format!( - "{SEAL_PROVIDER}=`{PROVIDER_EIDEASY}` but {} not set", - missing.join(", ") - ))); - } - - let base_url = base_url.expect("checked non-empty above"); - let environment = environment_for(&base_url)?; - - Ok(Some(Self { - base_url: base_url.trim_end_matches('/').to_owned(), - environment, - client_id: client_id.expect("checked non-empty above"), - hmac_key: Zeroizing::new(hmac_key.expect("checked non-empty above")), - signature_profile: read("SEAL_EIDEASY_SIGNATURE_PROFILE").unwrap_or_else(|| { - crate::eideasy::EsealRequest::PROFILE_CADES_BASELINE_T.to_owned() - }), - request_timeout: Duration::from_secs(30), - })) } } -/// Map a base URL to its eID Easy deployment, refusing anything else. -/// -/// The allowlist is the point: an unrecognised host cannot be classified, and a -/// seal whose trust tier we cannot state is worse than no seal. A typo in -/// `SEAL_EIDEASY_BASE_URL` fails the node at boot rather than shipping passport -/// digests to whatever host was actually spelled. -fn environment_for(base_url: &str) -> Result { - let parsed = url::Url::parse(base_url) - .map_err(|e| SealError::Config(format!("SEAL_EIDEASY_BASE_URL is not a URL: {e}")))?; - if parsed.scheme() != "https" { - return Err(SealError::Config(format!( - "SEAL_EIDEASY_BASE_URL must be https, got {}", - parsed.scheme() - ))); - } - match parsed.host_str() { - Some(SANDBOX_HOST) => Ok(EideasyEnvironment::Sandbox), - Some(PRODUCTION_HOST) => Ok(EideasyEnvironment::Production), - other => Err(SealError::Config(format!( - "SEAL_EIDEASY_BASE_URL host {} is not eID Easy — expected {SANDBOX_HOST} or {PRODUCTION_HOST}", - other.unwrap_or("") - ))), - } -} - -/// Config pointed at an arbitrary base URL, for tests that stand up a local mock. -/// -/// Deliberately bypasses [`environment_for`]: the host allowlist guards the -/// operator-facing `from_env` path, and applying it here would make the endpoint -/// untestable without reaching eID Easy. -#[cfg(test)] -pub(crate) fn test_config(base_url: &str) -> EideasyConfig { - EideasyConfig { - base_url: base_url.to_owned(), - environment: EideasyEnvironment::Sandbox, - client_id: "test-client".into(), - hmac_key: Zeroizing::new("test-key".into()), - signature_profile: "CAdES_BASELINE_T".into(), - request_timeout: Duration::from_secs(5), - } +/// The name of a backend's variable group that is set while no provider is +/// selected, if any. Each backend answers for its own prefix. +fn stray_backend_vars(get: &impl Fn(&str) -> Option) -> Option<&'static str> { + [ + ( + crate::eideasy::config::ENV_GROUP, + crate::eideasy::config::any_env_set(get), + ), + ( + crate::local::config::ENV_GROUP, + crate::local::config::any_env_set(get), + ), + ] + .into_iter() + .find_map(|(group, set)| set.then_some(group)) } #[cfg(test)] mod tests { use super::*; - /// Resolve against a fixed set of variables — no process environment, so - /// these are pure, parallel-safe, and cannot be perturbed by the developer's - /// shell. - fn resolve(vars: &[(&str, &str)]) -> Result, SealError> { - let owned: Vec<(String, String)> = vars + /// A lookup over a fixed set of variables — no process environment, so these + /// are pure and parallel-safe. + fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let owned: Vec<(String, String)> = pairs .iter() .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) .collect(); - EideasyConfig::resolve(|name| { + move |name: &str| { owned .iter() .find(|(k, _)| k == name) .map(|(_, v)| v.clone()) - }) - } - - const FULL: [(&str, &str); 4] = [ - (SEAL_PROVIDER, PROVIDER_EIDEASY), - ("SEAL_EIDEASY_BASE_URL", SANDBOX_BASE_URL), - ("SEAL_EIDEASY_CLIENT_ID", "client-id"), - ("SEAL_EIDEASY_HMAC_KEY", "hmac-key"), - ]; - - #[test] - fn no_provider_selected_is_a_ghost_not_an_error() { - assert!(resolve(&[]).unwrap().is_none()); - assert!( - resolve(&[(SEAL_PROVIDER, PROVIDER_NONE)]) - .unwrap() - .is_none() - ); - } - - #[test] - fn a_full_configuration_resolves() { - let cfg = resolve(&FULL).unwrap().expect("configured"); - assert_eq!(cfg.environment, EideasyEnvironment::Sandbox); - assert_eq!(cfg.client_id, "client-id"); - // Defaulted, not required. - assert_eq!(cfg.signature_profile, "CAdES_BASELINE_T"); - } - - /// The failure this selector exists to prevent: credentials present, provider - /// not selected. Silently ghosting here would downgrade a node that was - /// configured for qualified sealing into one with none. - #[test] - fn credentials_without_a_selected_provider_are_refused() { - let err = resolve(&FULL[1..]).expect_err("must not ghost silently"); - assert!(err.to_string().contains(SEAL_PROVIDER)); - } - - #[test] - fn a_partial_configuration_names_what_is_missing() { - let err = resolve(&FULL[..2]).expect_err("partial must fail"); - let msg = err.to_string(); - assert!(msg.contains("SEAL_EIDEASY_CLIENT_ID"), "{msg}"); - assert!(msg.contains("SEAL_EIDEASY_HMAC_KEY"), "{msg}"); - } - - #[test] - fn an_unknown_provider_fails_the_boot() { - let err = - resolve(&[(SEAL_PROVIDER, "some-other-qtsp")]).expect_err("unknown provider must fail"); - assert!(err.to_string().contains("some-other-qtsp")); + } } - /// Blank is not "set" — an operator commenting a value out leaves an empty - /// string behind, and that must read as absent rather than as a credential. #[test] - fn blank_values_count_as_unset() { - assert!( - resolve(&[(SEAL_PROVIDER, " ")]).unwrap().is_none(), - "a whitespace-only provider should ghost, not fail as unknown" - ); + fn unset_is_none() { + assert_eq!(SealProvider::resolve(env(&[])).unwrap(), SealProvider::None); } #[test] - fn known_hosts_map_to_their_environments() { + fn explicit_none_is_none() { assert_eq!( - environment_for(SANDBOX_BASE_URL).unwrap(), - EideasyEnvironment::Sandbox - ); - assert_eq!( - environment_for(PRODUCTION_BASE_URL).unwrap(), - EideasyEnvironment::Production + SealProvider::resolve(env(&[(SEAL_PROVIDER, "none")])).unwrap(), + SealProvider::None ); } #[test] - fn an_unknown_host_is_refused() { - // A typo must not become "some seal from somewhere" — it must not boot. - for url in [ - "https://id.eideasy.com.evil.test", - "https://eideasy.com", - "https://localhost:8080", - ] { - assert!( - environment_for(url).is_err(), - "{url} was accepted as an eID Easy host" - ); - } - } - - #[test] - fn plaintext_http_is_refused() { - assert!(environment_for("http://test.eideasy.com").is_err()); + fn an_unknown_provider_names_the_supported_ones() { + let msg = SealProvider::resolve(env(&[(SEAL_PROVIDER, "acme")])) + .unwrap_err() + .to_string(); + assert!(msg.contains("acme"), "{msg}"); + assert!(msg.contains(crate::local::config::PROVIDER), "{msg}"); } + /// Credentials without a selection is refused rather than ghosted. + /// + /// The failure this guards is a typo in `SEAL_PROVIDER` on a node that is + /// otherwise fully configured to seal: falling back would publish passports + /// with no qualified seal and no error. #[test] - fn debug_does_not_print_the_hmac_key() { - let mut cfg = test_config(SANDBOX_BASE_URL); - cfg.hmac_key = Zeroizing::new("super-secret-key-material".into()); - let rendered = format!("{cfg:?}"); - assert!( - !rendered.contains("super-secret-key-material"), - "Debug leaked the HMAC key: {rendered}" - ); - assert!(rendered.contains("[redacted]")); + fn backend_vars_without_a_selection_are_refused() { + let msg = SealProvider::resolve(env(&[("SEAL_LOCAL_KEY_PATH", "/tmp/k.pem")])) + .unwrap_err() + .to_string(); + assert!(msg.contains(SEAL_PROVIDER), "{msg}"); } } diff --git a/crates/dpp-seal/src/eideasy/client.rs b/crates/dpp-seal/src/eideasy/client.rs index 25db56c..2d24589 100644 --- a/crates/dpp-seal/src/eideasy/client.rs +++ b/crates/dpp-seal/src/eideasy/client.rs @@ -16,12 +16,19 @@ use std::time::{SystemTime, UNIX_EPOCH}; +use async_trait::async_trait; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; +use chrono::Utc; +use dpp_domain::ports::seal::{ + SealCapabilities, SealFormat, SealMode, SealRequest, SealedEnvelope, +}; use hmac::{Hmac, Mac}; use sha2::Sha256; -use crate::config::EideasyConfig; +use super::config::EideasyConfig; +use super::error::EideasyError; +use crate::backend::SealBackend; use crate::error::SealError; use super::types::{EsealFile, EsealRequest, EsealResponse}; @@ -51,10 +58,10 @@ struct SignedBody { } impl SignedBody { - fn new(req: &EsealRequest, hmac_key: &str, timestamp: u64) -> Result { + fn new(req: &EsealRequest, hmac_key: &str, timestamp: u64) -> Result { // The one and only serialization. let body = serde_json::to_string(req) - .map_err(|e| SealError::Protocol(format!("request body serialization: {e}")))?; + .map_err(|e| EideasyError::Protocol(format!("request body serialization: {e}")))?; let message = hmac_message(METHOD, ESEAL_PATH, timestamp, &body); let mut mac = @@ -85,10 +92,6 @@ impl EideasyClient { Ok(Self { http, config }) } - pub fn config(&self) -> &EideasyConfig { - &self.config - } - /// Seal one hex SHA-256 digest, returning the base64 detached CAdES `.p7s`. /// /// `file_name` is the correlation label eID Easy echoes back as @@ -115,7 +118,10 @@ impl EideasyClient { // `.body()`, never `.json()` — these are the bytes the HMAC covers. .body(signed.body) .send() - .await?; + .await + // Nothing reached the far side, so there is no status and no body to + // classify against — this is transport, not a provider answer. + .map_err(|e| SealError::Transport(e.to_string()))?; let status = response.status(); if !status.is_success() { @@ -138,26 +144,83 @@ impl EideasyClient { "eID Easy e-seal request failed" ); return Err(match status.as_u16() { - 429 => SealError::RateLimited { retry_after_secs }, - s @ (401 | 403) => SealError::Auth { + 429 => EideasyError::RateLimited { retry_after_secs }, + s @ (401 | 403) => EideasyError::Auth { status: s, hint: clock_hint(signed.timestamp, their_time), }, - s => SealError::Provider { status: s }, - }); + s => EideasyError::Provider { status: s }, + } + .into()); } - let parsed: EsealResponse = response.json().await?; + // A body that fails to decode is a protocol fault, not a transport one: + // the exchange completed, the bytes are just not what was promised. + let parsed: EsealResponse = response + .json() + .await + .map_err(|e| EideasyError::Protocol(e.to_string()))?; Ok(parsed.single_signature()?.file_content.clone()) } } +#[async_trait] +impl SealBackend for EideasyClient { + async fn seal(&self, req: SealRequest) -> Result { + // The digest doubles as the correlation label: eID Easy echoes `fileName` + // back, so deriving it from the digest makes a mismatched response visible + // without holding request state. No extension — see `EsealFile::file_name`. + let file_name = format!( + "dpp-{}", + &req.payload_hash[..req.payload_hash.len().min(16)] + ); + let seal_value = self.seal_digest(&file_name, &req.payload_hash).await?; + + // Which certificate the provider actually sealed with, read out of the + // `.p7s` it returned — **as reported by the seal**, never as verified. + // Whether that certificate was qualified, and on the EU Trusted List at + // this moment, is the independent validator's question; recording which + // one to ask about is what stops an auditor having to be handed the + // bytes and parse them by hand. + // + // A seal that cannot be parsed still stores: the envelope is what was + // bought, and losing it over an unfillable convenience field would + // trade something that matters for something that does not. + let signing_cert_ref = BASE64 + .decode(&seal_value) + .ok() + .and_then(|der| crate::cades::signer_certificate_thumbprint(&der).ok()) + .flatten(); + + Ok(SealedEnvelope { + format: SealFormat::Cades, + seal_value, + signing_cert_ref, + sealed_at: Utc::now(), + placeholder: false, + }) + } + + fn capabilities(&self) -> SealCapabilities { + // eID Easy Direct e-Sealing offers the CAdES profile only, and this node + // holds the seal on operators' behalf, so `ProviderSeal` is the one mode. + // `verify()` takes the trait's refusing default and no capability claims + // otherwise. + SealCapabilities { + supported_formats: vec![SealFormat::Cades], + supported_modes: vec![SealMode::ProviderSeal], + } + } +} + /// Current UNIX seconds for `X-Timestamp` (eID Easy allows 5 minutes of skew). fn unix_now() -> Result { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) - .map_err(|e| SealError::Protocol(format!("system clock is before the UNIX epoch: {e}"))) + // The node's clock, not the provider's answer — nothing about eID Easy + // classifies this one. + .map_err(|e| SealError::Config(format!("system clock is before the UNIX epoch: {e}"))) } /// The provider's clock, from the RFC 9110 `Date` header every response carries. @@ -185,12 +248,12 @@ const MAX_SKEW_SECS: u64 = 300; /// timestamp was acceptable and the rejection means something else, so pointing /// at the clock would send the operator down the wrong path just as surely as /// staying silent sends them to rotate a good key. -fn clock_hint(ours: u64, theirs: Option) -> crate::error::AuthHint { +fn clock_hint(ours: u64, theirs: Option) -> super::error::AuthHint { match theirs { Some(theirs) if ours.abs_diff(theirs) > MAX_SKEW_SECS => { - crate::error::AuthHint::ClockSkew { ours, theirs } + super::error::AuthHint::ClockSkew { ours, theirs } } - _ => crate::error::AuthHint::None, + _ => super::error::AuthHint::None, } } @@ -241,7 +304,7 @@ mod tests { #[test] fn a_clock_inside_the_window_is_not_blamed() { - use crate::error::AuthHint; + use super::super::error::AuthHint; // Inside the tolerance, the timestamp was fine — the 401 means something // else, and blaming the clock would misdirect exactly as badly. assert_eq!(clock_hint(1710000000, Some(1710000000)), AuthHint::None); @@ -252,7 +315,7 @@ mod tests { #[test] fn a_drifted_clock_is_named_in_the_error() { - use crate::error::AuthHint; + use super::super::error::AuthHint; let hint = clock_hint(1710000000, Some(1710000901)); assert!(matches!(hint, AuthHint::ClockSkew { .. })); // Drift in either direction counts. @@ -261,7 +324,7 @@ mod tests { AuthHint::ClockSkew { .. } )); - let rendered = SealError::Auth { status: 401, hint }.to_string(); + let rendered = EideasyError::Auth { status: 401, hint }.to_string(); assert!(rendered.contains("clock"), "{rendered}"); assert!( rendered.contains("before rotating"), diff --git a/crates/dpp-seal/src/eideasy/config.rs b/crates/dpp-seal/src/eideasy/config.rs new file mode 100644 index 0000000..7cd07fc --- /dev/null +++ b/crates/dpp-seal/src/eideasy/config.rs @@ -0,0 +1,287 @@ +//! Configuration for the eID Easy Cloud Direct e-Sealing backend. +//! +//! The HMAC key is an operator-supplied, node-wide credential for one outbound +//! service, needed at boot and never created at runtime — the same category as +//! `EU_REGISTRY_CLIENT_SECRET` and `MTLS_PROXY_SHARED_SECRET`, and delivered the +//! same way (the node's `.env`, mode 600). It is deliberately not +//! in the key store, which holds Ed25519 key pairs for DID publication, and not +//! in Postgres, which would put a live signing credential in every backup. + +use std::time::Duration; + +use zeroize::Zeroizing; + +use crate::error::SealError; + +/// Which eID Easy deployment the configured base URL points at. +/// +/// Derived from the host rather than configured separately, so the environment +/// and the URL cannot disagree. This is what the node maps to its trust tier — +/// a sandbox seal is a real seal from a real API, but it carries eID Easy's test +/// certificate and has no legal validity, which is a different claim from Ghost. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EideasyEnvironment { + /// `test.eideasy.com` — developer credentials, eID Easy test certificates. + Sandbox, + /// `id.eideasy.com` — production, bound to the qualified certificate. + Production, +} + +pub const SANDBOX_BASE_URL: &str = "https://test.eideasy.com"; +pub const PRODUCTION_BASE_URL: &str = "https://id.eideasy.com"; + +const SANDBOX_HOST: &str = "test.eideasy.com"; +const PRODUCTION_HOST: &str = "id.eideasy.com"; + +/// Resolved eID Easy configuration. +/// +/// `Debug` is implemented by hand: the derived one would print `hmac_key` in +/// full through any `{:?}` — a `tracing` field, an `anyhow` context, a panic +/// message. +#[derive(Clone)] +pub struct EideasyConfig { + /// Origin only, no trailing slash. The path is [`crate::eideasy::client::ESEAL_PATH`]. + pub base_url: String, + /// Which deployment `base_url` names. + pub environment: EideasyEnvironment, + /// From eID Easy "My Webpages". Not a secret — it travels in the request body. + pub client_id: String, + /// The e-seal HMAC key, shown once at generation. + pub hmac_key: Zeroizing, + /// Must be a profile eID Easy has enabled for `client_id`. + pub signature_profile: String, + pub request_timeout: Duration, +} + +impl std::fmt::Debug for EideasyConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EideasyConfig") + .field("base_url", &self.base_url) + .field("environment", &self.environment) + .field("client_id", &self.client_id) + .field("hmac_key", &"[redacted]") + .field("signature_profile", &self.signature_profile) + .field("request_timeout", &self.request_timeout) + .finish() + } +} + +/// The `SEAL_PROVIDER` value that selects this backend. +pub const PROVIDER: &str = "eideasy"; + +/// Human-readable name of this backend's variable group, for the message the +/// selector emits when these are set without the provider being chosen. +pub const ENV_GROUP: &str = "SEAL_EIDEASY_*"; + +const ENV_BASE_URL: &str = "SEAL_EIDEASY_BASE_URL"; +const ENV_CLIENT_ID: &str = "SEAL_EIDEASY_CLIENT_ID"; +const ENV_HMAC_KEY: &str = "SEAL_EIDEASY_HMAC_KEY"; +const ENV_SIGNATURE_PROFILE: &str = "SEAL_EIDEASY_SIGNATURE_PROFILE"; + +/// Whether any variable of this backend's group is set. +pub fn any_env_set(get: &impl Fn(&str) -> Option) -> bool { + [ENV_BASE_URL, ENV_CLIENT_ID, ENV_HMAC_KEY] + .iter() + .any(|n| get(n).is_some_and(|v| !v.trim().is_empty())) +} + +impl EideasyConfig { + /// Read configuration from the environment. + /// + /// Returns `Ok(None)` when no provider is selected — the node then wires + /// `GhostSeal`, which a development profile permits and a production profile + /// refuses at boot. + /// + /// A *partial* configuration is an error, not a `None`. Silently falling back + /// to a ghost because one of three variables was misspelled would downgrade a + /// node from qualified sealing to no sealing on a typo, which is precisely the + /// failure the trust report exists to make impossible. Setting the + /// `SEAL_EIDEASY_*` variables without selecting the provider is the same class + /// of mistake and is refused for the same reason. + /// + /// When a second backend lands this becomes a dispatch on + /// [`SEAL_PROVIDER`]; the shape is here so that change is additive. + pub fn from_env() -> Result { + Self::resolve(|name| std::env::var(name).ok()) + } + + /// The whole of [`Self::from_env`]'s logic, over an arbitrary lookup. + /// + /// Split out so the rules above — provider selection, all-or-nothing + /// credentials, host allowlisting — are testable as a pure function. + /// Exercising them through the real environment would mean mutating + /// process-global state from tests, which is `unsafe` under Rust 2024, forces + /// them to serialise against one another, and lets a stray variable in the + /// developer's shell change the result. + pub fn resolve(get: impl Fn(&str) -> Option) -> Result { + let read = |name: &str| { + get(name) + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) + }; + + let base_url = read(ENV_BASE_URL); + let client_id = read(ENV_CLIENT_ID); + let hmac_key = read(ENV_HMAC_KEY); + let missing: Vec<&str> = [ + (ENV_BASE_URL, &base_url), + (ENV_CLIENT_ID, &client_id), + (ENV_HMAC_KEY, &hmac_key), + ] + .iter() + .filter(|(_, v)| v.is_none()) + .map(|(n, _)| *n) + .collect(); + if !missing.is_empty() { + return Err(SealError::Config(format!( + "{} is selected but {} not set", + PROVIDER, + missing.join(", ") + ))); + } + + let base_url = base_url.expect("checked non-empty above"); + let environment = environment_for(&base_url)?; + + Ok(Self { + base_url: base_url.trim_end_matches('/').to_owned(), + environment, + client_id: client_id.expect("checked non-empty above"), + hmac_key: Zeroizing::new(hmac_key.expect("checked non-empty above")), + signature_profile: read(ENV_SIGNATURE_PROFILE).unwrap_or_else(|| { + crate::eideasy::EsealRequest::PROFILE_CADES_BASELINE_T.to_owned() + }), + request_timeout: Duration::from_secs(30), + }) + } +} + +/// Map a base URL to its eID Easy deployment, refusing anything else. +/// +/// The allowlist is the point: an unrecognised host cannot be classified, and a +/// seal whose trust tier we cannot state is worse than no seal. A typo in +/// `SEAL_EIDEASY_BASE_URL` fails the node at boot rather than shipping passport +/// digests to whatever host was actually spelled. +fn environment_for(base_url: &str) -> Result { + let parsed = url::Url::parse(base_url) + .map_err(|e| SealError::Config(format!("SEAL_EIDEASY_BASE_URL is not a URL: {e}")))?; + if parsed.scheme() != "https" { + return Err(SealError::Config(format!( + "SEAL_EIDEASY_BASE_URL must be https, got {}", + parsed.scheme() + ))); + } + match parsed.host_str() { + Some(SANDBOX_HOST) => Ok(EideasyEnvironment::Sandbox), + Some(PRODUCTION_HOST) => Ok(EideasyEnvironment::Production), + other => Err(SealError::Config(format!( + "SEAL_EIDEASY_BASE_URL host {} is not eID Easy — expected {SANDBOX_HOST} or {PRODUCTION_HOST}", + other.unwrap_or("") + ))), + } +} + +/// Config pointed at an arbitrary base URL, for tests that stand up a local mock. +/// +/// Deliberately bypasses [`environment_for`]: the host allowlist guards the +/// operator-facing `from_env` path, and applying it here would make the endpoint +/// untestable without reaching eID Easy. +#[cfg(test)] +pub(crate) fn test_config(base_url: &str) -> EideasyConfig { + EideasyConfig { + base_url: base_url.to_owned(), + environment: EideasyEnvironment::Sandbox, + client_id: "test-client".into(), + hmac_key: Zeroizing::new("test-key".into()), + signature_profile: "CAdES_BASELINE_T".into(), + request_timeout: Duration::from_secs(5), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Resolve against a fixed set of variables — no process environment, so + /// these are pure, parallel-safe, and cannot be perturbed by the developer's + /// shell. + fn resolve(vars: &[(&str, &str)]) -> Result { + let owned: Vec<(String, String)> = vars + .iter() + .map(|(k, v)| ((*k).to_owned(), (*v).to_owned())) + .collect(); + EideasyConfig::resolve(|name| { + owned + .iter() + .find(|(k, _)| k == name) + .map(|(_, v)| v.clone()) + }) + } + + const FULL: [(&str, &str); 3] = [ + (ENV_BASE_URL, SANDBOX_BASE_URL), + (ENV_CLIENT_ID, "client-id"), + (ENV_HMAC_KEY, "hmac-key"), + ]; + + #[test] + fn a_full_configuration_resolves() { + let cfg = resolve(&FULL).expect("configured"); + assert_eq!(cfg.environment, EideasyEnvironment::Sandbox); + assert_eq!(cfg.client_id, "client-id"); + // Defaulted, not required. + assert_eq!(cfg.signature_profile, "CAdES_BASELINE_T"); + } + + #[test] + fn a_partial_configuration_names_what_is_missing() { + let err = resolve(&FULL[..1]).expect_err("partial must fail"); + let msg = err.to_string(); + assert!(msg.contains("SEAL_EIDEASY_CLIENT_ID"), "{msg}"); + assert!(msg.contains("SEAL_EIDEASY_HMAC_KEY"), "{msg}"); + } + + #[test] + fn known_hosts_map_to_their_environments() { + assert_eq!( + environment_for(SANDBOX_BASE_URL).unwrap(), + EideasyEnvironment::Sandbox + ); + assert_eq!( + environment_for(PRODUCTION_BASE_URL).unwrap(), + EideasyEnvironment::Production + ); + } + + #[test] + fn an_unknown_host_is_refused() { + // A typo must not become "some seal from somewhere" — it must not boot. + for url in [ + "https://id.eideasy.com.evil.test", + "https://eideasy.com", + "https://localhost:8080", + ] { + assert!( + environment_for(url).is_err(), + "{url} was accepted as an eID Easy host" + ); + } + } + + #[test] + fn plaintext_http_is_refused() { + assert!(environment_for("http://test.eideasy.com").is_err()); + } + + #[test] + fn debug_does_not_print_the_hmac_key() { + let mut cfg = test_config(SANDBOX_BASE_URL); + cfg.hmac_key = Zeroizing::new("super-secret-key-material".into()); + let rendered = format!("{cfg:?}"); + assert!( + !rendered.contains("super-secret-key-material"), + "Debug leaked the HMAC key: {rendered}" + ); + assert!(rendered.contains("[redacted]")); + } +} diff --git a/crates/dpp-seal/src/eideasy/error.rs b/crates/dpp-seal/src/eideasy/error.rs new file mode 100644 index 0000000..50a8ab4 --- /dev/null +++ b/crates/dpp-seal/src/eideasy/error.rs @@ -0,0 +1,80 @@ +//! Failures that only mean something in terms of this provider's contract. +//! +//! Each variant here encodes something eID Easy specifically does — a +//! five-minute timestamp window, an unpublished rate limit, a status field that +//! can say `ERROR` inside a 200. Classified at the HTTP boundary, where the +//! status and the response headers are still in hand, then handed to the rest of +//! the crate as [`SealError::Backend`] — a rendered message, carrying no +//! provider vocabulary onward. + +use crate::error::SealError; + +/// Why a 401 most likely happened, when the response gives us enough to say. +/// +/// Derived by comparing our `X-Timestamp` against the provider's own `Date` +/// header — the one external clock reference we get for free on every response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthHint { + /// Nothing to add: the clock agreed, or the response carried no usable `Date`. + None, + /// Our clock is outside eID Easy's five-minute window, which alone explains + /// the rejection. Rotating the key here would be chasing the wrong fault. + ClockSkew { ours: u64, theirs: u64 }, +} + +impl std::fmt::Display for AuthHint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::None => Ok(()), + Self::ClockSkew { ours, theirs } => write!( + f, + " — but this node's clock reads {ours} and the provider's reads \ + {theirs}, a drift of {}s beyond the 5-minute window. Fix the clock \ + (NTP) before rotating the HMAC key; a skewed timestamp fails \ + identically to a bad key", + ours.abs_diff(*theirs) + ), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum EideasyError { + /// 401/403 — the HMAC key is wrong or rotated, e-seal credentials were never + /// enabled for this `client_id`, **or the node's clock has drifted**. + /// + /// The last one is why `hint` exists. `X-Timestamp` is inside the signed + /// message and eID Easy allows five minutes of skew, so a drifted clock + /// produces a 401 that is byte-for-byte the same as a bad key. Without a hint, + /// the operator rotates a perfectly good credential and the failure persists. + #[error("eID Easy rejected our credentials (HTTP {status}){hint}")] + Auth { status: u16, hint: AuthHint }, + + /// 429 — too many requests. + /// + /// Typed separately from [`Self::Provider`] because it is the one non-success + /// status that is neither our fault nor a provider fault, and the operator + /// response is different: not "check the credentials", but "the volume is + /// above what this contract allows". eID Easy publishes no rate limits, so + /// the drain's existing exponential backoff (to one hour) is the response; + /// this exists so the cause is nameable in the logs rather than buried in a + /// generic provider error. + #[error("eID Easy rate-limited us (HTTP 429){}", retry_after_secs.map(|s| format!("; Retry-After: {s}s")).unwrap_or_default())] + RateLimited { retry_after_secs: Option }, + + /// The call completed but the answer was not one we can act on — a non-`OK` + /// status field, an unparseable body, or a signature count that does not + /// match the digests we sent. + #[error("eID Easy protocol: {0}")] + Protocol(String), + + /// Any other non-success HTTP status. + #[error("eID Easy returned HTTP {status}")] + Provider { status: u16 }, +} + +impl From for SealError { + fn from(e: EideasyError) -> Self { + SealError::Backend(e.to_string()) + } +} diff --git a/crates/dpp-seal/src/eideasy/mod.rs b/crates/dpp-seal/src/eideasy/mod.rs index 8e07d9a..66d8919 100644 --- a/crates/dpp-seal/src/eideasy/mod.rs +++ b/crates/dpp-seal/src/eideasy/mod.rs @@ -4,11 +4,30 @@ //! HMAC-authenticated server-to-server digest sealing. The wire contract is //! eID Easy's published Cloud Direct e-Sealing API. //! +//! +//! Cloud Direct e-Sealing produces **CAdES** over the payload digest (not +//! JAdES). Auth is HMAC over the exact request bytes. CAdES from a qualified +//! QTSP carries the same eIDAS Art. 35 legal presumption as any other AdES +//! envelope; the DPP registry requires a *qualified* seal, not a specific +//! envelope format. +//! +//! Sandbox needs no legal entity — it seals with the provider's test +//! certificates. The entity gates *production* only. +//! //! - [`types`] — request/response wire types. -//! - [`client`] — the HMAC-signed POST, and the sign-the-exact-bytes invariant. +//! - [`client`] — the HMAC-signed POST, the sign-the-exact-bytes invariant, and +//! this backend's `SealBackend` implementation. +//! - [`error`] — the failures that only mean something against this contract. pub mod client; +pub mod config; +pub mod error; pub mod types; +#[cfg(test)] +mod tests; + pub use client::EideasyClient; +pub use config::{EideasyConfig, EideasyEnvironment}; +pub use error::{AuthHint, EideasyError}; pub use types::{EsealFile, EsealRequest, EsealResponse, EsealSignatureOut, MIME_JSON, MIME_PDF}; diff --git a/crates/dpp-seal/src/tests.rs b/crates/dpp-seal/src/eideasy/tests.rs similarity index 74% rename from crates/dpp-seal/src/tests.rs rename to crates/dpp-seal/src/eideasy/tests.rs index 5197006..e8b78b3 100644 --- a/crates/dpp-seal/src/tests.rs +++ b/crates/dpp-seal/src/eideasy/tests.rs @@ -19,6 +19,7 @@ use sha2::{Digest, Sha256}; use crate::adapter::QtspSealAdapter; use crate::eideasy::client::{ESEAL_PATH, hmac_message}; +use crate::eideasy::config::{SANDBOX_BASE_URL, test_config}; /// Base64 standing in for a real detached CAdES `.p7s`. const MOCK_P7S: &str = "TU9DSy1DQWRFUy1QN1M="; @@ -127,14 +128,14 @@ mod mock_server { use mock_server::MockState; fn adapter_for(base_url: &str, hmac_key: &str) -> QtspSealAdapter { - let mut cfg = crate::config::test_config(base_url); + let mut cfg = crate::eideasy::config::test_config(base_url); cfg.hmac_key = zeroize::Zeroizing::new(hmac_key.to_owned()); - QtspSealAdapter::eideasy(cfg).unwrap() + QtspSealAdapter::new(crate::eideasy::EideasyClient::new(cfg).unwrap()) } #[tokio::test] async fn unconfigured_adapter_delegates_to_ghost() { - let env = QtspSealAdapter::ghost() + let env = QtspSealAdapter::new(crate::ghost::GhostSeal) .seal(seal_request(fixture_digest())) .await .unwrap(); @@ -142,6 +143,16 @@ async fn unconfigured_adapter_delegates_to_ghost() { assert!(env.seal_value.starts_with("GHOST-SEAL-")); } +#[test] +fn this_backend_advertises_cades_only() { + let caps = QtspSealAdapter::new( + crate::eideasy::EideasyClient::new(test_config(SANDBOX_BASE_URL)).unwrap(), + ) + .capabilities(); + assert_eq!(caps.supported_formats, vec![SealFormat::Cades]); + assert_eq!(caps.supported_modes, vec![SealMode::ProviderSeal]); +} + #[tokio::test] async fn a_real_seal_round_trips_into_a_non_placeholder_envelope() { let state = Arc::new(MockState::default()); @@ -199,6 +210,65 @@ async fn a_wrong_hmac_key_is_a_clean_error_not_a_panic() { ); } +/// The certificate the provider sealed with is read back off the wire. +/// +/// The mock returns a genuine CMS structure rather than the placeholder string +/// the other tests use, because that is the only way to exercise the extraction +/// at all — and the value asserted is the signing identity's own thumbprint, so +/// this fails if the adapter ever reports a certificate other than the one the +/// seal actually names. +#[tokio::test] +async fn the_signing_certificate_is_read_out_of_the_returned_seal() { + let dir = tempfile::tempdir().expect("tempdir"); + let id = crate::local::LocalIdentity::load_or_create(dir.path()).expect("identity"); + let real_p7s = BASE64.encode(id.sign_detached(&[0x33; 32]).expect("sign")); + + let state = Arc::new(MockState::default()); + *state.force_response.lock().unwrap() = Some(( + axum::http::StatusCode::OK, + serde_json::json!({ + "status": "OK", + "signatures": [{ + "fileName": "dpp-abc.p7s", + "mimeType": "application/pkcs7-signature", + "fileContent": real_p7s, + }], + }) + .to_string(), + )); + let base_url = mock_server::spawn(state.clone()).await; + + let env = adapter_for(&base_url, MOCK_KEY) + .seal(seal_request(fixture_digest())) + .await + .expect("seal"); + + assert_eq!( + env.signing_cert_ref.as_deref(), + Some(id.cert_thumbprint().as_str()), + "the envelope must name the certificate inside the seal it received" + ); +} + +/// A seal the parser cannot read still produces an envelope. +/// +/// The seal was bought and is the thing that matters; an unfillable convenience +/// field must not cost it. Every other test in this file returns a placeholder +/// `.p7s` that is not CMS at all, so this is also what keeps them meaningful. +#[tokio::test] +async fn an_unparseable_seal_still_stores_without_a_certificate_reference() { + let state = Arc::new(MockState::default()); + let base_url = mock_server::spawn(state.clone()).await; + + let env = adapter_for(&base_url, MOCK_KEY) + .seal(seal_request(fixture_digest())) + .await + .expect("an unreadable seal is still a seal"); + + assert_eq!(env.seal_value, MOCK_P7S); + assert_eq!(env.signing_cert_ref, None); +} + #[tokio::test] async fn a_non_ok_status_body_does_not_become_a_seal() { let state = Arc::new(MockState::default()); diff --git a/crates/dpp-seal/src/eideasy/types.rs b/crates/dpp-seal/src/eideasy/types.rs index 5b9ff3c..ed82bc9 100644 --- a/crates/dpp-seal/src/eideasy/types.rs +++ b/crates/dpp-seal/src/eideasy/types.rs @@ -15,7 +15,7 @@ use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use serde::{Deserialize, Serialize}; -use crate::error::SealError; +use super::error::EideasyError; /// The `mimeType` we declare. eID Easy built Direct e-Sealing for PDF, and both /// their docs and their support answer use this value; it is what we send. @@ -65,11 +65,11 @@ impl EsealFile { pub fn from_hex_digest( file_name: impl Into, hex_digest: &str, - ) -> Result { + ) -> Result { let raw = hex::decode(hex_digest) - .map_err(|e| SealError::Protocol(format!("payload hash is not hex: {e}")))?; + .map_err(|e| EideasyError::Protocol(format!("payload hash is not hex: {e}")))?; if raw.len() != 32 { - return Err(SealError::Protocol(format!( + return Err(EideasyError::Protocol(format!( "payload hash must be a 32-byte SHA-256 digest, got {} bytes", raw.len() ))); @@ -155,16 +155,16 @@ impl EsealResponse { /// A non-`OK` status, or a count other than one, means the response does not /// answer the request we made — a protocol fault, rather than something to /// reach into `signatures[0]` and hope about. - pub fn single_signature(&self) -> Result<&EsealSignatureOut, SealError> { + pub fn single_signature(&self) -> Result<&EsealSignatureOut, EideasyError> { if !self.is_ok() { - return Err(SealError::Protocol(format!( + return Err(EideasyError::Protocol(format!( "status was {:?}, expected \"OK\"", self.status ))); } match self.signatures.as_slice() { [one] => Ok(one), - other => Err(SealError::Protocol(format!( + other => Err(EideasyError::Protocol(format!( "expected exactly 1 signature for 1 digest, got {}", other.len() ))), diff --git a/crates/dpp-seal/src/error.rs b/crates/dpp-seal/src/error.rs index 64728a7..b450111 100644 --- a/crates/dpp-seal/src/error.rs +++ b/crates/dpp-seal/src/error.rs @@ -1,83 +1,39 @@ -//! Typed errors for the eID Easy backend. +//! The crate's error type — what is true of sealing regardless of backend. //! //! `SealPort` returns `DppError`, which is deliberately coarse — every adapter //! collapses into `DppError::Internal` at that boundary. This type exists on the -//! near side of it so a failure is classified *once*, where the HTTP status and -//! the response body are still in hand, rather than reconstructed from a string -//! by whoever reads the log. +//! near side of it so a failure is classified *once*, where it happened, rather +//! than reconstructed from a string by whoever reads the log. +//! +//! A backend's own failure modes are its own business and live in its module; +//! they arrive here already classified and already rendered, as +//! [`SealError::Backend`]. What stays here is only what any backend can hit: +//! configuration, transport, and an operation nobody implements. use dpp_domain::domain::error::DppError; -/// Why a 401 most likely happened, when the response gives us enough to say. -/// -/// Derived by comparing our `X-Timestamp` against the provider's own `Date` -/// header — the one external clock reference we get for free on every response. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AuthHint { - /// Nothing to add: the clock agreed, or the response carried no usable `Date`. - None, - /// Our clock is outside eID Easy's five-minute window, which alone explains - /// the rejection. Rotating the key here would be chasing the wrong fault. - ClockSkew { ours: u64, theirs: u64 }, -} - -impl std::fmt::Display for AuthHint { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::None => Ok(()), - Self::ClockSkew { ours, theirs } => write!( - f, - " — but this node's clock reads {ours} and the provider's reads \ - {theirs}, a drift of {}s beyond the 5-minute window. Fix the clock \ - (NTP) before rotating the HMAC key; a skewed timestamp fails \ - identically to a bad key", - ours.abs_diff(*theirs) - ), - } - } -} - #[derive(Debug, thiserror::Error)] pub enum SealError { - /// Configuration is absent, partial, or names a host that is not eID Easy. - #[error("eID Easy configuration: {0}")] + /// Configuration is absent, partial, or names something unusable. + #[error("seal configuration: {0}")] Config(String), - /// 401/403 — the HMAC key is wrong or rotated, e-seal credentials were never - /// enabled for this `client_id`, **or the node's clock has drifted**. - /// - /// The last one is why `hint` exists. `X-Timestamp` is inside the signed - /// message and eID Easy allows five minutes of skew, so a drifted clock - /// produces a 401 that is byte-for-byte the same as a bad key. Without a hint, - /// the operator rotates a perfectly good credential and the failure persists. - #[error("eID Easy rejected our credentials (HTTP {status}){hint}")] - Auth { status: u16, hint: AuthHint }, - - /// 429 — too many requests. - /// - /// Typed separately from [`Self::Provider`] because it is the one non-success - /// status that is neither our fault nor a provider fault, and the operator - /// response is different: not "check the credentials", but "the volume is - /// above what this contract allows". eID Easy publishes no rate limits, so - /// the drain's existing exponential backoff (to one hour) is the response; - /// this exists so the cause is nameable in the logs rather than buried in a - /// generic provider error. - #[error("eID Easy rate-limited us (HTTP 429){}", retry_after_secs.map(|s| format!("; Retry-After: {s}s")).unwrap_or_default())] - RateLimited { retry_after_secs: Option }, - /// The request never completed: DNS, connection, TLS, or timeout. - #[error("eID Easy transport: {0}")] + /// + /// Not a backend fault and not ours — nothing reached the far side, so + /// there is no status and no body to classify against. + #[error("seal transport: {0}")] Transport(String), - /// The call completed but the answer was not one we can act on — a non-`OK` - /// status field, an unparseable body, or a signature count that does not - /// match the digests we sent. - #[error("eID Easy protocol: {0}")] - Protocol(String), - - /// Any other non-success HTTP status. - #[error("eID Easy returned HTTP {status}")] - Provider { status: u16 }, + /// A backend failed in a way only that backend can describe. + /// + /// Carried as its rendered message rather than as a type: nothing outside + /// this crate matches on the cause, and lifting every backend's variants + /// into a shared enum would put each provider's vocabulary — status codes, + /// skew windows, rate limits — in front of code that must stay ignorant of + /// which backend is wired. + #[error("{0}")] + Backend(String), /// An operation this adapter does not implement, stated rather than faked. #[error("{0}")] @@ -89,15 +45,3 @@ impl From for DppError { DppError::Internal(e.to_string()) } } - -impl From for SealError { - fn from(e: reqwest::Error) -> Self { - // A body that fails to decode is a protocol fault, not a transport one: - // the exchange completed, the bytes are just not what was promised. - if e.is_decode() { - SealError::Protocol(e.to_string()) - } else { - SealError::Transport(e.to_string()) - } - } -} diff --git a/crates/dpp-seal/src/ghost.rs b/crates/dpp-seal/src/ghost.rs new file mode 100644 index 0000000..a89d428 --- /dev/null +++ b/crates/dpp-seal/src/ghost.rs @@ -0,0 +1,47 @@ +//! The placeholder backend: synthetic envelopes, no legal validity. +//! +//! `GhostSeal` is the port's own placeholder, so there is nothing to implement +//! here beyond making it a [`SealBackend`] like any other. That it satisfies the +//! same trait as a live provider is the point — a node with no provider takes +//! the identical path, and the difference shows up as `placeholder: true` on the +//! envelope and a `Ghost` trust tier at boot, which a production profile refuses +//! to start on. + +use async_trait::async_trait; +use dpp_domain::ports::seal::{ + SealCapabilities, SealPort, SealRequest, SealVerification, SealedEnvelope, +}; +use tracing::warn; + +pub use dpp_domain::ports::seal::GhostSeal; + +use crate::backend::SealBackend; +use crate::error::SealError; + +#[async_trait] +impl SealBackend for GhostSeal { + async fn seal(&self, req: SealRequest) -> Result { + warn!( + "no seal provider configured — sealing with GhostSeal (placeholder, no legal validity)" + ); + SealPort::seal(self, req).await.map_err(backend_err) + } + + fn capabilities(&self) -> SealCapabilities { + // The placeholder genuinely produces (synthetic) seals — report what + // `GhostSeal` actually does rather than claiming nothing. + SealPort::capabilities(self) + } + + /// Overrides the refusing default: `GhostSeal` verifies, and its answer is + /// honest — `valid: false`, `placeholder: true`. Refusing here would hide + /// the one thing a caller most needs to learn from a ghost. + async fn verify(&self, env: &SealedEnvelope) -> Result { + SealPort::verify(self, env).await.map_err(backend_err) + } +} + +/// `GhostSeal` speaks the port's coarse error; nothing here can re-classify it. +fn backend_err(e: dpp_domain::domain::error::DppError) -> SealError { + SealError::Backend(e.to_string()) +} diff --git a/crates/dpp-seal/src/lib.rs b/crates/dpp-seal/src/lib.rs index 355d138..f018c05 100644 --- a/crates/dpp-seal/src/lib.rs +++ b/crates/dpp-seal/src/lib.rs @@ -3,24 +3,14 @@ //! # The sealing model //! //! A qualified electronic seal is produced by a Qualified Trust Service -//! Provider (QTSP) — this node never holds the seal's private key and never -//! assembles an AdES signature in-process (no Rust AdES library exists; the -//! provider's response *is* the seal). Until a provider is configured, +//! Provider (QTSP) — for that seal this node never holds the private key and +//! never assembles the signature in-process; the provider's response *is* the +//! seal. The local backend does assemble a CMS structure in-process, which is +//! precisely why it is not qualified. Until a provider is configured, //! [`adapter::QtspSealAdapter`] delegates to `GhostSeal` — a placeholder with //! no legal validity, which is why a production node's trust report refuses //! to boot while the seal port resolves to a ghost. //! -//! # Provider -//! -//! **eID Easy Cloud Direct e-Sealing**, which aggregates qualified QTSPs and -//! produces **CAdES** over the payload digest. Auth is HMAC over the exact -//! request bytes. CAdES from a qualified QTSP carries the same eIDAS -//! Art. 35 legal presumption as any other AdES envelope; the DPP registry -//! requires a *qualified* seal, not a specific envelope format. -//! -//! Sandbox needs no legal entity — it seals with eID Easy test certificates. -//! The entity gates *production* only. -//! //! # What is sealed //! //! The digest handed to `SealPort::seal` is over the passport's `jwsSignature` @@ -32,19 +22,32 @@ //! //! # Structure //! -//! - [`adapter`] — `QtspSealAdapter`, the `SealPort` impl (eID Easy or ghost) -//! - [`config`] — `EideasyConfig`, resolved from the environment -//! - [`eideasy`] — Cloud Direct e-Sealing wire types and HTTP client -//! - [`error`] — `SealError`, classified once at the HTTP boundary +//! - [`backend`] — `SealBackend`, the seam every backend implements +//! - [`cades`] — reading what a detached CAdES reports about itself, shared by +//! the backends and never claiming a check it did not perform +//! - [`adapter`] — `QtspSealAdapter`, the `SealPort` impl over one of them +//! - [`config`] — which backend this node runs, and nothing about any of them +//! - [`eideasy`] — a hosted QTSP backend: its config, wire types, client and errors +//! - [`local`] — in-process signing for development +//! - [`ghost`] — the placeholder, as a backend like any other +//! - [`error`] — `SealError`, what is true of sealing regardless of backend +//! +//! Each backend owns its own module: its configuration, its variables, its +//! failure messages and its wire types, and it constructs itself. Nothing +//! outside a backend's module names it — the adapter holds a `dyn SealBackend` +//! and the selector maps one environment value to one module — so a backend can +//! be added or dropped without touching the others. pub mod adapter; +pub mod backend; +pub mod cades; pub mod config; pub mod eideasy; pub mod error; +pub mod ghost; +pub mod local; pub use adapter::QtspSealAdapter; -pub use config::{EideasyConfig, EideasyEnvironment}; +pub use backend::SealBackend; +pub use config::{SEAL_PROVIDER, SealProvider}; pub use error::SealError; - -#[cfg(test)] -mod tests; diff --git a/crates/dpp-seal/src/local/config.rs b/crates/dpp-seal/src/local/config.rs new file mode 100644 index 0000000..d6cd16f --- /dev/null +++ b/crates/dpp-seal/src/local/config.rs @@ -0,0 +1,77 @@ +//! Configuration for the local development sealing backend. + +use std::path::PathBuf; + +use crate::error::SealError; + +/// The `SEAL_PROVIDER` value that selects this backend. +pub const PROVIDER: &str = "local"; + +/// Human-readable name of this backend's variable group, for the message the +/// selector emits when these are set without the provider being chosen. +pub const ENV_GROUP: &str = "SEAL_LOCAL_*"; + +const ENV_KEY_PATH: &str = "SEAL_LOCAL_KEY_PATH"; + +/// Whether any variable of this backend's group is set. +pub fn any_env_set(get: &impl Fn(&str) -> Option) -> bool { + [ENV_KEY_PATH] + .iter() + .any(|n| get(n).is_some_and(|v| !v.trim().is_empty())) +} + +/// Resolved configuration for the local backend. +#[derive(Debug, Clone)] +pub struct LocalConfig { + /// Where the generated key and certificate are persisted between runs. + /// + /// Persisted rather than regenerated per boot so a seal produced yesterday + /// still verifies against the same certificate today — a restart that + /// silently invalidated every seal it had produced would teach a developer + /// the wrong thing about how seals behave. + pub key_path: PathBuf, +} + +impl LocalConfig { + /// Read configuration from the process environment. + pub fn from_env() -> Result { + Self::resolve(|name| std::env::var(name).ok()) + } + + /// The whole of [`Self::from_env`]'s logic over an arbitrary lookup, for the + /// same reason the other backends split it: process-global state is `unsafe` + /// to mutate under Rust 2024 and makes tests serialise against each other. + pub fn resolve(get: impl Fn(&str) -> Option) -> Result { + let key_path = get(ENV_KEY_PATH) + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) + .map_or_else(|| PathBuf::from("./.seal-local"), PathBuf::from); + + Ok(Self { key_path }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_path_defaults_when_unset() { + let cfg = LocalConfig::resolve(|_| None).unwrap(); + assert_eq!(cfg.key_path, PathBuf::from("./.seal-local")); + } + + #[test] + fn key_path_is_read_from_the_environment() { + let cfg = + LocalConfig::resolve(|n| (n == ENV_KEY_PATH).then(|| "/var/lib/odal/seal".to_owned())) + .unwrap(); + assert_eq!(cfg.key_path, PathBuf::from("/var/lib/odal/seal")); + } + + #[test] + fn a_blank_value_is_not_a_path() { + let cfg = LocalConfig::resolve(|n| (n == ENV_KEY_PATH).then(|| " ".to_owned())).unwrap(); + assert_eq!(cfg.key_path, PathBuf::from("./.seal-local")); + } +} diff --git a/crates/dpp-seal/src/local/mod.rs b/crates/dpp-seal/src/local/mod.rs new file mode 100644 index 0000000..6cdab3b --- /dev/null +++ b/crates/dpp-seal/src/local/mod.rs @@ -0,0 +1,17 @@ +//! Local development sealing backend. +//! +//! Signs in-process with a locally generated key instead of calling a trust +//! service provider. The point is to exercise the whole pipeline — port +//! contract, digest handling, envelope, storage, verification path — without a +//! provider account, a contract, or a sandbox credential. +//! +//! **The certificate is not on an EU Trusted List.** That is a property of the +//! certificate, not of this code: nothing in the signing or verification path +//! differs between a self-signed key and a qualified one. What differs is the +//! legal weight, which is none. + +pub mod config; +pub mod sealer; + +pub use config::LocalConfig; +pub use sealer::LocalIdentity; diff --git a/crates/dpp-seal/src/local/sealer.rs b/crates/dpp-seal/src/local/sealer.rs new file mode 100644 index 0000000..e34b66c --- /dev/null +++ b/crates/dpp-seal/src/local/sealer.rs @@ -0,0 +1,611 @@ +//! A real detached CMS `SignedData` over the payload digest, signed locally. +//! +//! # What this is, and what it is not +//! +//! It **is** a genuine CMS signature: the bytes verify against the certificate, +//! the structure is what a provider returns, and every stage of the pipeline +//! that handles a seal handles this one identically. The digest travels in +//! `signedAttrs`, as CAdES requires, so the envelope is self-checking — a holder +//! of the bytes alone can confirm the signature, which is what lets this backend +//! answer `verify()` when the hosted one cannot. +//! +//! It is **not** qualified, and cannot become so. The certificate is +//! self-signed and on no EU Trusted List, which is a property of the +//! certificate rather than of this code — nothing in the signing or +//! verification path differs between a self-signed key and a QTSP-held one. +//! What differs is the legal weight, which is none. The node reflects that by +//! resolving this backend's trust tier to `Ghost`, so a production profile +//! refuses to boot on it. +//! +//! A second, narrower limit worth stating: the OpenID4VC High Assurance +//! Interoperability Profile requires that an issuer's signing certificate +//! **not** be self-signed. So this backend can never stand in for a real issuer +//! in a conformant credential flow, however complete the pipeline around it is. + +use std::path::Path; + +use async_trait::async_trait; +use base64::Engine as _; +use chrono::Utc; +use cms::cert::{CertificateChoices, IssuerAndSerialNumber}; +use cms::content_info::ContentInfo; +use cms::signed_data::{ + CertificateSet, DigestAlgorithmIdentifiers, EncapsulatedContentInfo, SignedAttributes, + SignedData, SignerIdentifier, SignerInfo, SignerInfos, +}; +use const_oid::db::rfc5911::ID_DATA; +use der::{Any, Decode as _, Encode}; +use dpp_domain::ports::seal::{ + SealCapabilities, SealFormat, SealMode, SealRequest, SealVerification, SealedEnvelope, +}; +use p256::ecdsa::{DerSignature, SigningKey}; +use x509_cert::Certificate; +use x509_cert::attr::Attribute; + +use crate::backend::SealBackend; +use crate::error::SealError; + +/// A locally generated signing identity: one key, one self-signed certificate. +pub struct LocalIdentity { + key: SigningKey, + cert: Certificate, + cert_der: Vec, +} + +impl LocalIdentity { + /// Load the identity at `dir`, generating it on first use. + /// + /// Persisted rather than regenerated per boot: a seal produced yesterday + /// must still verify against the same certificate today. A restart that + /// silently invalidated every seal it had produced would teach the wrong + /// thing about how seals behave. + pub fn load_or_create(dir: &Path) -> Result { + let key_path = dir.join("seal-key.pkcs8.der"); + let cert_path = dir.join("seal-cert.der"); + + if key_path.exists() && cert_path.exists() { + let key_der = std::fs::read(&key_path).map_err(io_err("read the local seal key"))?; + let cert_der = + std::fs::read(&cert_path).map_err(io_err("read the local seal certificate"))?; + return Self::from_der(&key_der, cert_der); + } + + let (key_der, cert_der) = generate()?; + std::fs::create_dir_all(dir).map_err(io_err("create the local seal directory"))?; + std::fs::write(&key_path, &key_der).map_err(io_err("write the local seal key"))?; + std::fs::write(&cert_path, &cert_der) + .map_err(io_err("write the local seal certificate"))?; + Self::from_der(&key_der, cert_der) + } + + fn from_der(key_der: &[u8], cert_der: Vec) -> Result { + use p256::pkcs8::DecodePrivateKey as _; + let key = SigningKey::from_pkcs8_der(key_der) + .map_err(|e| SealError::Config(format!("local seal key is not a P-256 PKCS#8: {e}")))?; + let cert = Certificate::from_der(cert_der.as_slice()).map_err(|e| { + SealError::Config(format!("local seal certificate is not valid DER: {e}")) + })?; + Ok(Self { + key, + cert, + cert_der, + }) + } + + /// SHA-256 of the certificate, as the envelope's `signing_cert_ref`. + pub fn cert_thumbprint(&self) -> String { + use sha2::{Digest as _, Sha256}; + hex::encode(Sha256::digest(&self.cert_der)) + } + + /// Produce a **detached** CMS `SignedData` over `digest`. + /// + /// Detached is the point: `eContent` is absent, so the signature travels + /// separately from what it covers — the same arrangement a provider returns + /// and the same one the passport's `jwsSignature` expects. + pub fn sign_detached(&self, digest: &[u8]) -> Result, SealError> { + use der::asn1::{OctetString, SetOfVec}; + use p256::ecdsa::signature::Signer as _; + + // Assembled from `cms`'s own types rather than its `builder` feature. + // That feature depends unconditionally on `rsa`, which carries + // RUSTSEC-2023-0071 with no fixed upgrade; we sign with P-256 and have + // no use for RSA, so the dependency would be pure advisory surface on + // the one crate that produces seals. + // + // Detached: `eContent` is absent, so what was signed travels separately + // from the signature — the arrangement a provider returns. The *digest* + // does travel, in `signedAttrs` below; that is what detached CAdES does, + // and it is the difference between a seal that can be checked and one + // that cannot. + let econtent = EncapsulatedContentInfo { + econtent_type: ID_DATA, + econtent: None, + }; + + let digest_algorithm = x509_cert::spki::AlgorithmIdentifierOwned { + oid: const_oid::db::rfc5912::ID_SHA_256, + parameters: None, + }; + + // Signed attributes carry the digest *inside* the signature, which is + // what makes the seal checkable at all. With `signedAttrs` absent the + // signature covers the digest directly, and a verifier holding only the + // envelope — which is all `SealPort::verify` is given — has no way to + // reconstruct what was signed. Attaching `messageDigest` is also what + // CAdES requires, so this is the faithful shape rather than a + // concession. + let signed_attrs = signed_attributes(digest)?; + + // RFC 5652 §5.4: the signature is computed over the DER **SET OF** + // encoding of the signed attributes, not over the `[0] IMPLICIT` form + // they take inside `SignerInfo`. Encoding the wrong one produces a + // signature that verifies nowhere, including here. + let to_sign = signed_attrs + .to_der() + .map_err(|e| SealError::Config(format!("cannot encode the signed attributes: {e}")))?; + let signature: DerSignature = self.key.sign(&to_sign); + + let signer_info = SignerInfo { + version: cms::content_info::CmsVersion::V1, + sid: SignerIdentifier::IssuerAndSerialNumber(IssuerAndSerialNumber { + issuer: self.cert.tbs_certificate.issuer.clone(), + serial_number: self.cert.tbs_certificate.serial_number.clone(), + }), + digest_alg: digest_algorithm.clone(), + signed_attrs: Some(signed_attrs), + signature_algorithm: x509_cert::spki::AlgorithmIdentifierOwned { + oid: const_oid::db::rfc5912::ECDSA_WITH_SHA_256, + parameters: None, + }, + signature: OctetString::new(signature.to_bytes().as_ref()) + .map_err(|e| SealError::Config(format!("cannot encode the signature: {e}")))?, + unsigned_attrs: None, + }; + + let mut digest_algorithms = SetOfVec::new(); + digest_algorithms + .insert(digest_algorithm) + .map_err(|e| SealError::Config(format!("cannot record the digest algorithm: {e}")))?; + + let mut certs = SetOfVec::new(); + certs + .insert(CertificateChoices::Certificate(self.cert.clone())) + .map_err(|e| SealError::Config(format!("cannot attach the certificate: {e}")))?; + + let mut signer_infos = SetOfVec::new(); + signer_infos + .insert(signer_info) + .map_err(|e| SealError::Config(format!("cannot attach the signer info: {e}")))?; + + let signed_data = SignedData { + version: cms::content_info::CmsVersion::V1, + digest_algorithms: DigestAlgorithmIdentifiers::from(digest_algorithms), + encap_content_info: econtent, + certificates: Some(CertificateSet::from(certs)), + crls: None, + signer_infos: SignerInfos::from(signer_infos), + }; + + let info = ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: Any::encode_from(&signed_data) + .map_err(|e| SealError::Config(format!("cannot encode the SignedData: {e}")))?, + }; + info.to_der() + .map_err(|e| SealError::Config(format!("cannot DER-encode the seal: {e}"))) + } + + /// When this identity's certificate was generated. + pub fn generated_at(&self) -> chrono::DateTime { + Utc::now() + } +} + +#[async_trait] +impl SealBackend for LocalIdentity { + async fn seal(&self, req: SealRequest) -> Result { + let digest = hex::decode(&req.payload_hash) + .map_err(|e| SealError::Config(format!("payload hash is not hex: {e}")))?; + let der = self.sign_detached(&digest)?; + + Ok(SealedEnvelope { + format: SealFormat::Cades, + seal_value: base64::engine::general_purpose::STANDARD.encode(&der), + signing_cert_ref: Some(self.cert_thumbprint()), + sealed_at: Utc::now(), + // Not a placeholder: these bytes verify. Legal standing is the trust + // tier's business, not the envelope's. + placeholder: false, + }) + } + + fn capabilities(&self) -> SealCapabilities { + // Real detached CAdES bytes. `OperatorSeal` because the certificate is + // generated per node rather than held centrally on operators' behalf — + // this backend rehearses the shape the hosted arrangement takes. + SealCapabilities { + supported_formats: vec![SealFormat::Cades], + supported_modes: vec![SealMode::OperatorSeal], + } + } + + /// Overrides the refusing default, because this backend can answer honestly. + /// + /// The default exists so that no backend claims a verdict it did not + /// compute, and the reason a hosted QTSP's seal cannot be answered here is + /// independence: a verdict from the node that bought the seal attests + /// nothing. Neither objection applies to this backend. Its seals make no + /// trust claim at all beyond "this key signed this digest", so a + /// cryptographic check *is* the whole truth about them, and there is no + /// authority whose independence could be borrowed or faked. + /// + /// So `valid: true` here means exactly what [`SealVerification::valid`] + /// documents — the seal cryptographically verifies — and nothing more. It + /// carries no legal weight, because the certificate is self-signed. The node + /// says that separately and structurally, by resolving this backend to the + /// `Ghost` trust tier so a production profile refuses to boot on it. + async fn verify(&self, env: &SealedEnvelope) -> Result { + use base64::engine::general_purpose::STANDARD as BASE64; + + let der = BASE64 + .decode(&env.seal_value) + .map_err(|e| SealError::Backend(format!("seal value is not base64: {e}")))?; + + Ok(SealVerification { + valid: crate::cades::verify_against_embedded_certificate(&der)?, + placeholder: env.placeholder, + }) + } +} + +/// The attributes the signature covers: what was signed, and its digest. +/// +/// Two, both mandatory under RFC 5652 §11 for a signature carrying signed +/// attributes: `contentType`, and `messageDigest` holding the digest the caller +/// asked to seal. The second is the one that matters here — it is what puts the +/// sealed value inside the signature, so a holder of the bytes alone can check +/// them. +fn signed_attributes(digest: &[u8]) -> Result { + use der::asn1::{OctetString, SetOfVec}; + + let attr = |oid, value: Any| -> Result { + let mut values = SetOfVec::new(); + values + .insert(value) + .map_err(|e| SealError::Config(format!("cannot build a signed attribute: {e}")))?; + Ok(Attribute { oid, values }) + }; + + let content_type = attr( + const_oid::db::rfc5911::ID_CONTENT_TYPE, + Any::encode_from(&ID_DATA) + .map_err(|e| SealError::Config(format!("cannot encode the content type: {e}")))?, + )?; + let message_digest = attr( + const_oid::db::rfc5911::ID_MESSAGE_DIGEST, + Any::encode_from( + &OctetString::new(digest) + .map_err(|e| SealError::Config(format!("cannot encode the digest: {e}")))?, + ) + .map_err(|e| SealError::Config(format!("cannot encode the digest attribute: {e}")))?, + )?; + + let mut attrs = SetOfVec::new(); + for a in [content_type, message_digest] { + attrs + .insert(a) + .map_err(|e| SealError::Config(format!("cannot collect signed attributes: {e}")))?; + } + Ok(attrs) +} + +/// Generate a P-256 key and a self-signed certificate for it. +fn generate() -> Result<(Vec, Vec), SealError> { + let mut params = rcgen::CertificateParams::new(vec!["odal-local-seal".to_owned()]) + .map_err(|e| SealError::Config(format!("cannot build certificate params: {e}")))?; + params.distinguished_name.push( + rcgen::DnType::CommonName, + "Odal Node local development seal", + ); + params + .distinguished_name + .push(rcgen::DnType::OrganizationName, "NOT A QUALIFIED SEAL"); + + let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256) + .map_err(|e| SealError::Config(format!("cannot generate a P-256 key: {e}")))?; + let cert = params + .self_signed(&key) + .map_err(|e| SealError::Config(format!("cannot self-sign the certificate: {e}")))?; + + Ok((key.serialize_der(), cert.der().to_vec())) +} + +fn io_err(what: &'static str) -> impl Fn(std::io::Error) -> SealError { + move |e| SealError::Config(format!("cannot {what}: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use cms::content_info::ContentInfo; + + fn identity() -> (LocalIdentity, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("tempdir"); + let id = LocalIdentity::load_or_create(dir.path()).expect("identity"); + (id, dir) + } + + /// The seal is a real CMS `ContentInfo` carrying `SignedData`, not a stub. + /// + /// Parsing it back with the same library a provider's output would be parsed + /// with is the check that matters: a structure that only this code can read + /// would prove nothing about the pipeline. + #[test] + fn the_seal_parses_back_as_cms_signed_data() { + let (id, _dir) = identity(); + let der = id.sign_detached(&[0x42; 32]).expect("sign"); + + let info = ContentInfo::from_der(&der).expect("a CMS ContentInfo"); + assert_eq!(info.content_type, const_oid::db::rfc5911::ID_SIGNED_DATA); + + let sd: cms::signed_data::SignedData = info.content.decode_as().expect("SignedData inside"); + assert_eq!(sd.signer_infos.0.len(), 1, "exactly one signer"); + assert!( + sd.certificates.is_some(), + "the signing certificate travels with the seal, as a provider's does" + ); + } + + /// The signature in the seal verifies against the certificate it carries. + /// + /// This is the test the whole backend exists for. Everything else here + /// checks structure; without this one, a seal could be well-formed CMS + /// carrying bytes that verify against nothing — which is precisely what + /// `GhostSeal` already produces, and what this backend is meant to stop + /// being. + #[test] + fn the_signature_verifies_against_the_embedded_certificate() { + let (id, _dir) = identity(); + let der = id.sign_detached(&[0x7u8; 32]).expect("sign"); + + // Checked through the same function production uses, which is handed + // bytes and nothing else — the identity in memory is not consulted. + assert!( + crate::cades::verify_against_embedded_certificate(&der) + .expect("the seal is well-formed"), + "the seal must verify against the certificate it ships" + ); + } + + /// The seal commits to the digest it was asked to seal, not to some other. + /// + /// `cades::verify_against_embedded_certificate` proves the signature covers the signed attributes; this + /// proves the signed attributes carry the right value. Without it the + /// backend could sign a constant attribute set perfectly and attest nothing + /// about the passport — internally valid, externally meaningless. + #[test] + fn the_signed_attributes_carry_the_requested_digest() { + use der::asn1::OctetString; + + let (id, _dir) = identity(); + let digest = [0x5u8; 32]; + let der = id.sign_detached(&digest).expect("sign"); + + let info = ContentInfo::from_der(&der).expect("ContentInfo"); + let sd: cms::signed_data::SignedData = info.content.decode_as().expect("SignedData"); + let si = sd.signer_infos.0.as_slice().first().expect("one signer"); + let attrs = si.signed_attrs.as_ref().expect("signed attributes present"); + + let found = attrs + .as_slice() + .iter() + .find(|a| a.oid == const_oid::db::rfc5911::ID_MESSAGE_DIGEST) + .expect("a messageDigest attribute"); + let carried: OctetString = found + .values + .as_slice() + .first() + .expect("one value") + .decode_as() + .expect("an OCTET STRING"); + + assert_eq!( + carried.as_bytes(), + digest, + "the seal must commit to the digest it was handed" + ); + } + + /// A tampered seal does not verify. + /// + /// The check that makes the others mean something: if flipping a byte of the + /// signature still verified, `cades::verify_against_embedded_certificate` would be reporting a constant + /// rather than performing a check. + #[test] + fn a_tampered_signature_does_not_verify() { + let (id, _dir) = identity(); + let der = id.sign_detached(&[0x9u8; 32]).expect("sign"); + + // Flip a byte deep inside the structure. Some positions corrupt the DER + // and produce a parse error rather than `false`; both are refusals, and + // neither may be a pass. + let mut tampered = der.clone(); + let last = tampered.len() - 1; + tampered[last] ^= 0xff; + + assert!( + !matches!( + crate::cades::verify_against_embedded_certificate(&tampered), + Ok(true) + ), + "a tampered seal must never verify" + ); + } + + /// A seal with no signed attributes is refused, not called invalid. + /// + /// The digest is not inside such a seal, so nothing can be checked from the + /// envelope alone. Reporting `valid: false` would brand it broken on the + /// strength of a check that never ran — the distinction this crate exists to + /// keep. + #[test] + fn a_seal_without_signed_attributes_is_refused() { + let (id, _dir) = identity(); + let der = id.sign_detached(&[0x1u8; 32]).expect("sign"); + + let info = ContentInfo::from_der(&der).expect("ContentInfo"); + let mut sd: cms::signed_data::SignedData = info.content.decode_as().expect("SignedData"); + let mut signers = sd.signer_infos.0.as_slice().to_vec(); + signers[0].signed_attrs = None; + let mut rebuilt = der::asn1::SetOfVec::new(); + rebuilt.insert(signers.remove(0)).expect("one signer"); + sd.signer_infos = SignerInfos::from(rebuilt); + + let stripped = ContentInfo { + content_type: const_oid::db::rfc5911::ID_SIGNED_DATA, + content: Any::encode_from(&sd).expect("re-encode"), + } + .to_der() + .expect("DER"); + + let err = crate::cades::verify_against_embedded_certificate(&stripped) + .expect_err("must refuse rather than answer"); + assert!(err.to_string().contains("signed attributes"), "{err}"); + } + + /// Two different digests produce two different seals. + /// + /// Guards the failure that would make every other test here vacuous: a + /// backend that returns a constant would satisfy "parses as CMS" and still + /// attest nothing. + #[test] + fn a_different_digest_produces_a_different_seal() { + let (id, _dir) = identity(); + let a = id.sign_detached(&[0x01; 32]).expect("sign a"); + let b = id.sign_detached(&[0x02; 32]).expect("sign b"); + assert_ne!(a, b, "the seal must depend on what it covers"); + } + + fn seal_request(payload_hash: &str) -> SealRequest { + SealRequest { + payload_hash: payload_hash.to_owned(), + mode: SealMode::OperatorSeal, + key_ref: dpp_domain::ports::seal::SealCredentialRef { + qtsp_id: "local".into(), + credential_id: "dev".into(), + }, + sig_format: SealFormat::Cades, + } + } + + /// The envelope this backend hands the port carries the real signature and + /// says so. + /// + /// `placeholder: false` is the load-bearing field: the drain, the trust + /// report and the passport all read it, and marking these bytes as a + /// placeholder would hide a seal that genuinely verifies — while marking a + /// ghost's bytes as real would do far worse. + #[tokio::test] + async fn the_envelope_carries_the_signature_and_the_certificate() { + use base64::engine::general_purpose::STANDARD as BASE64; + + let (id, _dir) = identity(); + let digest = [0x33u8; 32]; + let env = SealBackend::seal(&id, seal_request(&hex::encode(digest))) + .await + .expect("the local backend seals"); + + assert_eq!(env.format, SealFormat::Cades); + assert!(!env.placeholder, "these bytes verify — they are not a stub"); + assert_eq!( + env.signing_cert_ref.as_deref(), + Some(id.cert_thumbprint().as_str()), + "the envelope must name the certificate that signed it" + ); + + // The seal value is the base64 of the same CMS the direct path produces. + let der = BASE64.decode(&env.seal_value).expect("base64 seal value"); + ContentInfo::from_der(&der).expect("a CMS ContentInfo"); + assert_eq!(der, id.sign_detached(&digest).expect("sign")); + } + + /// Seal, then verify, through the port — the round trip an operator gets. + /// + /// Every other test here reaches into the structure. This one only uses what + /// a caller has: a request in, an envelope out, and that envelope back in. + /// It is the whole point of a development backend — the pipeline is + /// exercised end to end without a provider account. + #[tokio::test] + async fn a_sealed_envelope_verifies_through_the_port() { + let (id, _dir) = identity(); + let digest = hex::encode([0x2Au8; 32]); + + let env = SealBackend::seal(&id, seal_request(&digest)) + .await + .expect("seal"); + let verdict = SealBackend::verify(&id, &env).await.expect("verify"); + + assert!( + verdict.valid, + "a seal this backend just produced must verify" + ); + assert!( + !verdict.placeholder, + "these bytes are real; only the trust behind them is absent" + ); + } + + /// A seal whose value was altered in storage fails the round trip. + /// + /// Without this, `a_sealed_envelope_verifies_through_the_port` would pass + /// against a `verify` that returned `true` unconditionally. + #[tokio::test] + async fn a_corrupted_envelope_does_not_verify_through_the_port() { + use base64::engine::general_purpose::STANDARD as BASE64; + + let (id, _dir) = identity(); + let mut env = SealBackend::seal(&id, seal_request(&hex::encode([0x2Au8; 32]))) + .await + .expect("seal"); + + let mut raw = BASE64.decode(&env.seal_value).expect("base64"); + let last = raw.len() - 1; + raw[last] ^= 0xff; + env.seal_value = BASE64.encode(&raw); + + assert!( + !matches!( + SealBackend::verify(&id, &env).await.map(|v| v.valid), + Ok(true) + ), + "a corrupted seal must never verify" + ); + } + + /// A digest that is not hex fails before any signing happens. + #[tokio::test] + async fn a_payload_hash_that_is_not_hex_is_refused() { + let (id, _dir) = identity(); + let err = SealBackend::seal(&id, seal_request("not-a-digest")) + .await + .expect_err("a malformed digest must not be signed over"); + assert!(err.to_string().contains("not hex"), "{err}"); + } + + /// The identity survives a restart. + /// + /// A seal produced before a restart must still verify against the same + /// certificate after one. + #[test] + fn the_identity_is_stable_across_loads() { + let dir = tempfile::tempdir().expect("tempdir"); + let first = LocalIdentity::load_or_create(dir.path()).expect("first"); + let second = LocalIdentity::load_or_create(dir.path()).expect("second"); + assert_eq!( + first.cert_thumbprint(), + second.cert_thumbprint(), + "reloading must not mint a new certificate" + ); + } +} diff --git a/crates/dpp-types/src/seal.rs b/crates/dpp-types/src/seal.rs index 08546ef..7ceeca5 100644 --- a/crates/dpp-types/src/seal.rs +++ b/crates/dpp-types/src/seal.rs @@ -130,6 +130,29 @@ pub trait SealOutbox: Send + Sync { /// next drain pass would buy it again. async fn mark_sealed(&self, id: uuid::Uuid, envelope: &SealedEnvelope) -> Result<(), DppError>; + /// The digest the passport's stored seal was requested over. + /// + /// [`SealedEnvelope`] carries no preimage, so without this a node cannot tell + /// a current seal from one superseded by a later re-publish — it can only + /// hand both values to an external validator and let that validator extract + /// the signed message digest. But the row that bought the seal *does* carry + /// the preimage, and rows are never deleted, so the answer is already held + /// here and was only ever a query away. + /// + /// The latest `sealed` row is the one whose envelope is on the passport: + /// [`Self::mark_sealed`] writes the envelope and closes the row in the same + /// transaction, so the two cannot disagree about which seal is current. + /// + /// This is the node's own record, not proof — it says what was *asked* for, + /// not what the CAdES actually covers. Only an independent validator + /// establishes the latter, and it is the cross-check for this value rather + /// than a substitute for it. + /// + /// `None` when this node holds no sealed row: a seal restored from a backup + /// or produced elsewhere is one whose preimage this node cannot vouch for, + /// and saying so beats guessing. + async fn sealed_digest(&self, passport_id: PassportId) -> Result, DppError>; + /// Transient failure: increment `attempts`, back `next_attempt_at` off /// exponentially, keep the row `pending`. async fn mark_attempt_failed(&self, id: uuid::Uuid, message: String) -> Result<(), DppError>; @@ -141,4 +164,22 @@ pub trait SealOutbox: Send + Sync { /// Counts by status, for boot logs and gauges. async fn status_counts(&self) -> Result; + + /// How many **published passports carry no seal at all**, right now. + /// + /// Not derivable from [`Self::status_counts`], and this is the whole reason + /// it exists. Those counts describe *rows*, and the failure that matters + /// most leaves no row: [`Self::enqueue`] runs after commit, so a crash in + /// that window publishes a passport that nothing will ever seal. An outbox + /// reporting `pending: 0, exhausted: 0` is consistent with any number of + /// unsealed passports, so a status view built on rows alone would show all + /// clear while the obligation went unmet. + /// + /// Counts passports, not rows, and takes no `limit`: it answers "is anything + /// unsealed", which a capped query cannot. + /// + /// Read-only. [`Self::enqueue_unsealed`] is the repair for the same + /// condition and shares this predicate, but adds its own guards for rows the + /// drain already owns — those belong to the repair, not to the question. + async fn unsealed_published_count(&self) -> Result; } diff --git a/crates/dpp-types/src/trust.rs b/crates/dpp-types/src/trust.rs index 15791e4..8f115cd 100644 --- a/crates/dpp-types/src/trust.rs +++ b/crates/dpp-types/src/trust.rs @@ -18,7 +18,10 @@ use serde::Serialize; /// Trust tier a resolved adapter operates at. Gauge encoding: Ghost=0, Sandbox=1, /// Live=2 (`trust_mode{port="…"}`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +/// Ordered deliberately: `Ghost < Sandbox < Live`. The ordering is what lets a +/// profile state a floor rather than enumerate the tiers it rejects, so adding a +/// tier later does not silently pass an existing guard. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)] #[serde(rename_all = "lowercase")] pub enum TrustMode { /// Placeholder — no real trust authority behind it (test double). @@ -57,27 +60,51 @@ impl TrustMode { } } -/// Deployment profile. Defaults to `Development`; `NODE_PROFILE=production` -/// opts into the strict boot guard. +/// Deployment profile — which trust tiers this environment will boot on. +/// +/// Three environments, not two, because "sandbox" is a property of the +/// **deployment** rather than a tier a production node may quietly carry. A +/// sandbox node is a full node in every respect except that the authorities +/// behind it are test ones; running it separately is the closest rehearsal of +/// production there is, and keeping the profiles apart is what stops a test +/// certificate ever sealing a passport that claims to be real. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum NodeProfile { /// Ghosts allowed; the default and the licensed dev-environment profile. Development, - /// Ghosts on required ports are a hard boot failure. + /// A real deployment against **test** authorities. Ghosts on required ports + /// are a hard boot failure, exactly as in production — what differs is that + /// `Sandbox` tiers are accepted, so the environment can be exercised + /// end-to-end without a production credential. + Sandbox, + /// Ghosts **and** sandboxes on required ports are a hard boot failure. + /// Only `Live` will do: a production node states that its passports are + /// backed by real authorities, and a sandbox tier makes that untrue. Production, } impl NodeProfile { /// Read `NODE_PROFILE` from the environment. Anything other than - /// `production` (including unset) is `Development`. + /// `production` or `sandbox` (including unset) is `Development`. #[must_use] pub fn from_env() -> Self { match std::env::var("NODE_PROFILE").ok().as_deref() { Some("production") => Self::Production, + Some("sandbox") => Self::Sandbox, _ => Self::Development, } } + + /// The lowest trust tier this profile will boot a required port on. + #[must_use] + pub fn minimum_tier(self) -> Option { + match self { + Self::Development => None, + Self::Sandbox => Some(TrustMode::Sandbox), + Self::Production => Some(TrustMode::Live), + } + } } /// One resolved trust port and the tier it operates at. @@ -127,17 +154,33 @@ impl NodeTrustReport { /// # Errors /// The offending-port message when a production node would boot on ghosts. pub fn enforce_profile(&self) -> Result<(), String> { - if self.profile != NodeProfile::Production { + let Some(minimum) = self.profile.minimum_tier() else { return Ok(()); - } - let ghosts = self.ghosted_required(); - if ghosts.is_empty() { + }; + + // Below the floor, not merely ghosted. `Production` demands `Live`, so a + // sandbox tier fails it too — a production node asserts that real + // authorities stand behind its passports, and a provider's test + // certificate makes that assertion false while looking identical. + let below: Vec<&str> = self + .ports + .iter() + .filter(|p| p.required && p.mode < minimum) + .map(|p| p.port) + .collect(); + if below.is_empty() { return Ok(()); } + + let profile = match self.profile { + NodeProfile::Production => "production", + NodeProfile::Sandbox => "sandbox", + NodeProfile::Development => "development", + }; Err(format!( - "NODE_PROFILE=production refuses to boot: required trust port(s) [{}] resolved to a \ - placeholder (ghost). Configure a real adapter or run with NODE_PROFILE=development.", - ghosts.join(", ") + "NODE_PROFILE={profile} refuses to boot: required trust port(s) [{}] resolved below `{}`. Configure a real adapter, or run a profile that admits the tier you have.", + below.join(", "), + minimum.as_str() )) } @@ -236,4 +279,63 @@ mod tests { assert_eq!(j["trust_mode"]["registry_sync"], "sandbox"); assert_eq!(j["trust_mode"]["archive"], "live"); } + + /// A production node refuses a **sandbox** tier, not only a ghost. + /// + /// This is the separation stated as a test. Before it, `Production` + /// admitted `Sandbox`, so a production deployment could seal passports with + /// a provider's test certificate and assert nothing was wrong — the two + /// look identical from outside, and only the tier distinguishes them. + #[test] + fn production_refuses_a_sandbox_seal() { + let report = NodeTrustReport::new( + NodeProfile::Production, + ports(TrustMode::Sandbox, TrustMode::Live, TrustMode::Live), + ); + let err = report + .enforce_profile() + .expect_err("production must not boot on a test certificate"); + assert!(err.contains("seal"), "{err}"); + assert!(err.contains("live"), "the message names the floor: {err}"); + } + + /// A sandbox node boots on sandbox tiers, and still refuses ghosts. + /// + /// The point of the profile: a full rehearsal of production against test + /// authorities. Admitting ghosts too would make it a development node with + /// a different name. + #[test] + fn sandbox_admits_sandbox_but_not_ghost() { + let ok = NodeTrustReport::new( + NodeProfile::Sandbox, + ports(TrustMode::Sandbox, TrustMode::Sandbox, TrustMode::Ghost), + ); + assert!(ok.enforce_profile().is_ok(), "sandbox tiers are the point"); + + let bad = NodeTrustReport::new( + NodeProfile::Sandbox, + ports(TrustMode::Ghost, TrustMode::Sandbox, TrustMode::Live), + ); + assert!( + bad.enforce_profile().is_err(), + "a ghost on a required port is a boot failure in sandbox too" + ); + } + + /// Development boots on anything, including all ghosts. + #[test] + fn development_admits_every_tier() { + let report = NodeTrustReport::new( + NodeProfile::Development, + ports(TrustMode::Ghost, TrustMode::Ghost, TrustMode::Ghost), + ); + assert!(report.enforce_profile().is_ok()); + } + + /// The ordering the floor comparison relies on. + #[test] + fn trust_tiers_are_ordered_ghost_sandbox_live() { + assert!(TrustMode::Ghost < TrustMode::Sandbox); + assert!(TrustMode::Sandbox < TrustMode::Live); + } } diff --git a/crates/dpp-vault/src/handlers/seal.rs b/crates/dpp-vault/src/handlers/seal.rs index 582ee6d..bb95ed6 100644 --- a/crates/dpp-vault/src/handlers/seal.rs +++ b/crates/dpp-vault/src/handlers/seal.rs @@ -7,10 +7,19 @@ //! signature it actually attests to, and with the digest a verifier needs to //! check it against. //! -//! What this route does **not** do is validate the CAdES. No Rust AdES validator -//! exists, and a seal is worth exactly as much as the independence of whoever -//! checked it — so the response carries everything an external validator needs -//! and states plainly what has and has not been verified. +//! What this route does **not** do is validate the CAdES. A seal is worth +//! exactly as much as the independence of whoever checked it, so a verdict from +//! the node that bought the seal would attest nothing — the response instead +//! carries everything an external validator needs and states plainly what has +//! and has not been verified. +//! +//! It does answer one narrower question, because it can: **is this seal stale?** +//! The envelope carries no preimage, but the outbox row that bought it does, and +//! those rows are never deleted — so a passport re-published after sealing is +//! detectable here with a lookup and a string comparison, no AdES tooling +//! involved. That is a record of what was *requested*, not proof of what the +//! CAdES covers; the validator's extracted digest is the cross-check, and +//! `coverage` never pretends to be the verdict. use axum::{ Json, @@ -35,29 +44,79 @@ pub struct SealResponse { pub seal_value: String, /// When the QTSP produced it. pub sealed_at: chrono::DateTime, + + /// Hex SHA-256 of the certificate the seal names as its signer, **as + /// reported by the seal** — read out of the CAdES, never verified. + /// + /// It answers *which* certificate to ask about, not whether that certificate + /// was qualified or on the EU Trusted List when the seal was made. Both of + /// those are the independent validator's question. Without this an auditor + /// has to be handed the `.p7s` and parse it by hand to learn even the first. + /// + /// `null` when the seal predates extraction or could not be parsed. + pub signing_cert_ref: Option, /// True when this is a `GhostSeal` placeholder with no legal validity. pub placeholder: bool, /// The passport's **current** compact JWS. pub current_jws: String, /// Hex SHA-256 of `currentJws` — the digest a seal over this passport's - /// present signature would have been taken over. - /// - /// Deliberately labelled "current" rather than "sealed": `SealedEnvelope` - /// does not record its own preimage, so this node cannot assert that the - /// seal above was taken over *this* digest. It is one half of the comparison, - /// and the external validator supplies the other half by extracting the - /// signed message digest from the CAdES itself. + /// present signature would be taken over. pub current_payload_hash: String, + + /// Hex SHA-256 this node **asked** the backend to seal, from the outbox row + /// that bought `sealValue`. + /// + /// `null` when this node holds no such row — a seal restored from a backup + /// or produced elsewhere. This is a record, not proof: it says what was + /// requested, and the validator's extracted message digest is what says what + /// the CAdES actually covers. The two agreeing is the cross-check. + pub sealed_payload_hash: Option, + + /// Whether the stored seal covers the passport's current signature. + pub coverage: Coverage, /// Stated, not implied: this node did not cryptographically validate the /// CAdES, and says so rather than letting the response read as a verdict. pub verification: &'static str, } const NOT_VALIDATED: &str = "not validated by this node — a detached CAdES must be checked by an independent AdES \ - validator against the EU Trusted List. That check also establishes whether this seal covers \ - currentPayloadHash: compare the validator's extracted message digest against it. A mismatch \ - means the passport was re-published after sealing and a seal over the new signature has not \ - landed yet — the seal remains valid for the signature it does cover."; + validator against the EU Trusted List. `coverage` answers a narrower question from this \ + node's own records and is not a substitute: it reports which digest was requested, while \ + only the validator establishes which digest the CAdES actually covers. Compare the two."; + +/// Whether the stored seal covers the passport's current signature. +/// +/// Answered from `sealedPayloadHash`, which is this node's record of what it +/// asked for. That is weaker than a validator's verdict and stronger than +/// nothing: it cannot confirm the CAdES, but a passport re-published after +/// sealing is knowable here without any AdES tooling at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Coverage { + /// The requested digest is the passport's current one. + Current, + /// The passport was re-published after this seal was bought. The seal stays + /// valid for the signature it does cover; a seal over the new signature has + /// not landed yet. + Superseded, + /// No record of what was sealed — restored from a backup, produced by + /// another node, or sealed before this node kept the row. Only the external + /// validator can answer. + Unknown, +} + +/// The coverage rule, as a pure function over the two digests. +/// +/// Split out from the handler so it is testable without a database: the whole +/// rule is which of three answers a pair of digests warrants, and that should not +/// need Postgres and an `AppState` to exercise. +fn coverage_of(sealed: Option<&str>, current: &str) -> Coverage { + match sealed { + Some(sealed) if sealed == current => Coverage::Current, + Some(_) => Coverage::Superseded, + None => Coverage::Unknown, + } +} /// `GET /api/v1/dpp/{dppId}/seal` — return the qualified seal and its preimage. /// @@ -95,6 +154,18 @@ pub async fn seal_handler( }; let payload_hash = seal_digest(&passport).unwrap_or_default(); + // A node with no outbox wired (no seal provider selected) can still be + // serving seals it bought earlier, so an absent outbox is `Unknown` rather + // than an error — the same answer as a row this node never had. + let sealed_payload_hash = match &state.service.seal_outbox { + Some(outbox) => match outbox.sealed_digest(passport_id).await { + Ok(h) => h, + Err(e) => return internal_error(e), + }, + None => None, + }; + let coverage = coverage_of(sealed_payload_hash.as_deref(), &payload_hash); + ( StatusCode::OK, Json(SealResponse { @@ -104,11 +175,124 @@ pub async fn seal_handler( .unwrap_or_default(), seal_value: seal.seal_value.clone(), sealed_at: seal.sealed_at, + signing_cert_ref: seal.signing_cert_ref.clone(), placeholder: seal.placeholder, current_jws: jws, current_payload_hash: payload_hash, + sealed_payload_hash, + coverage, verification: NOT_VALIDATED, }), ) .into_response() } + +/// Operator-wide sealing state. +/// +/// `unsealedPublished` is the headline and the other three are context, not the +/// other way round. The counts describe outbox *rows*; the obligation is about +/// *passports*, and the two come apart exactly where it matters most — a crash +/// between commit and enqueue publishes a passport that no row will ever cover, +/// so `pending: 0, exhausted: 0` is consistent with any number of unsealed +/// passports. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SealSummaryResponse { + /// Published passports carrying no seal at all. `0` is the healthy state. + pub unsealed_published: i64, + /// Rows awaiting a sealing attempt. + pub pending: i64, + /// Rows whose seal is on the passport. + pub sealed: i64, + /// Rows that gave up after exhausting their retries. + pub exhausted: i64, + /// False when no seal provider is configured, in which case every number + /// above is `0` because this node has no outbox — not because it has + /// nothing outstanding. Stated so a reader cannot mistake "not sealing" for + /// "all sealed". + pub sealing_configured: bool, +} + +/// `GET /api/v1/seal` — operator-wide sealing state. +/// +/// Exists because the per-passport route cannot answer "is anything unsealed" +/// without the caller already knowing which passport to ask about, and the +/// gauges that do answer it are only reachable through Prometheus. +pub async fn seal_summary_handler( + State(state): State, + Extension(_auth): Extension, +) -> impl IntoResponse { + let Some(outbox) = state.service.seal_outbox.as_ref() else { + return ( + StatusCode::OK, + Json(SealSummaryResponse { + unsealed_published: 0, + pending: 0, + sealed: 0, + exhausted: 0, + sealing_configured: false, + }), + ) + .into_response(); + }; + + let counts = match outbox.status_counts().await { + Ok(c) => c, + Err(e) => return internal_error(e), + }; + let unsealed_published = match outbox.unsealed_published_count().await { + Ok(n) => n, + Err(e) => return internal_error(e), + }; + + ( + StatusCode::OK, + Json(SealSummaryResponse { + unsealed_published, + pending: counts.pending, + sealed: counts.sealed, + exhausted: counts.exhausted, + sealing_configured: true, + }), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "aa"; + const B: &str = "bb"; + + #[test] + fn a_matching_digest_is_current() { + assert_eq!(coverage_of(Some(A), A), Coverage::Current); + } + + /// The case the whole lookup exists for: the passport was re-published, so + /// the stored seal covers a signature it no longer carries. + #[test] + fn a_differing_digest_is_superseded() { + assert_eq!(coverage_of(Some(A), B), Coverage::Superseded); + } + + /// No record is not the same as no coverage. + /// + /// A seal restored from a backup is very likely current; this node simply + /// cannot say so, and reporting `superseded` would brand a sound passport as + /// stale on the strength of a missing row. + #[test] + fn no_record_is_unknown_rather_than_superseded() { + assert_eq!(coverage_of(None, A), Coverage::Unknown); + } + + /// The wire values are part of the published contract. + #[test] + fn coverage_serialises_to_the_documented_strings() { + let rendered = |c: Coverage| serde_json::to_string(&c).expect("serialise"); + assert_eq!(rendered(Coverage::Current), "\"current\""); + assert_eq!(rendered(Coverage::Superseded), "\"superseded\""); + assert_eq!(rendered(Coverage::Unknown), "\"unknown\""); + } +} diff --git a/crates/dpp-vault/src/public_view.rs b/crates/dpp-vault/src/public_view.rs index 10b21fc..6a32aa6 100644 --- a/crates/dpp-vault/src/public_view.rs +++ b/crates/dpp-vault/src/public_view.rs @@ -350,6 +350,50 @@ pub(crate) mod tests { assert_eq!(served["publicJwsSignature"], json!(jws)); } + /// No audience is ever served a seal without a legible declaring party. + /// + /// A seal proves that a document came from whoever holds the certificate. It + /// says nothing about *scope* — "we vouch for this content" and "we + /// transmitted this intact" look identical. So a view carrying a seal and no + /// legible declarer invites the reader to conclude the sealer authored the + /// content, whatever anyone intended. + /// + /// Today the invariant holds the easy way, by stripping `seal` from every + /// audience. This test is written against the property rather than the + /// mechanism, so it keeps meaning if that ever changes: whoever serves a seal + /// to an audience must serve a declarer with it. + #[test] + fn no_audience_gets_a_seal_without_a_declarer() { + let passport = stub_passport(); + let mut full = serde_json::to_value(&passport).expect("serialise"); + full["seal"] = json!({ + "format": "CADES", + "sealValue": "p7s", + "sealedAt": "2026-08-14T00:00:00Z", + "placeholder": false, + }); + + for audience in [ + Audience::Public, + Audience::LegitimateInterest, + Audience::Authority, + ] { + // The passport's own version, as every production caller passes it. + let view = audience_view(&full, "battery", &passport.schema_version, audience); + let declarer = view + .get("manufacturer") + .and_then(|m| m.get("name")) + .and_then(Value::as_str) + .filter(|n| !n.is_empty()); + + assert!( + view.get("seal").is_none() || declarer.is_some(), + "{audience:?} received a seal with no legible declaring party — a reader \ + has no way to tell who vouched for this content from who sealed it" + ); + } + } + /// A published passport with no public proof is a corrupt row: fail closed /// rather than fall back to the live view and silently restore the drift. #[test] diff --git a/crates/dpp-vault/src/router.rs b/crates/dpp-vault/src/router.rs index dd4a14d..907765a 100644 --- a/crates/dpp-vault/src/router.rs +++ b/crates/dpp-vault/src/router.rs @@ -48,7 +48,7 @@ use crate::{ }, registry_status::{passport_registry_handler, registry_rollup_handler}, scan_ingest::{scan_ingest_handler, scan_ingest_mtls}, - seal::seal_handler, + seal::{seal_handler, seal_summary_handler}, stats::{operator_stats_handler, passport_stats_handler}, suspend::suspend_handler, transfer::{transfer_accept_handler, transfer_initiate_handler}, @@ -93,6 +93,9 @@ pub fn build(state: AppState) -> Router { // every audience view — it covers the full-payload signature, so it // verifies against no redaction. .route("/dpp/{dppId}/seal", get(seal_handler)) + // The operator-wide counterpart: the per-passport route cannot answer + // "is anything unsealed" without already knowing which passport to ask. + .route("/seal", get(seal_summary_handler)) // ── Scan telemetry (aggregate, privacy-safe) ────────────────── .route("/dpp/{dppId}/stats", get(passport_stats_handler)) .route("/stats", get(operator_stats_handler)) diff --git a/crates/dpp-vault/tests/helpers/mod.rs b/crates/dpp-vault/tests/helpers/mod.rs index b566bed..dac7836 100644 --- a/crates/dpp-vault/tests/helpers/mod.rs +++ b/crates/dpp-vault/tests/helpers/mod.rs @@ -20,7 +20,7 @@ use testcontainers::{ use dpp_dal::pg::{ PgApiKeyRepo, PgAuditRepo, PgDal, PgEvidenceDossierRepo, PgOperatorConfigRepo, PgPassportRepo, PgRegistryIdentityRepo, PgRegistrySyncRepo, PgRegistryTransferRepo, PgScanTelemetryRepo, - PgWebhookRepo, sqlx, + PgSealOutboxRepo, PgWebhookRepo, sqlx, }; use dpp_domain::{ DppError, GhostArchive, GhostRegistrySync, @@ -317,6 +317,10 @@ async fn start_vault_with_identity( // unconfigured. .with_registry_outbox(Arc::new(PgRegistrySyncRepo::new(dal.clone()))) .with_transfer_outbox(Arc::new(PgRegistryTransferRepo::new(dal.clone()))) + // Same reasoning as the two above: any node with a seal provider + // selected wires this, so a harness without it reports the sealing + // surface as unconfigured and cannot exercise it at all. + .with_seal_outbox(Arc::new(PgSealOutboxRepo::new(dal.clone()))) .with_evidence_store(Arc::new(PgEvidenceDossierRepo::new(dal.clone()))), ); let operator_service = Arc::new(OperatorService::new(operator_repo)); diff --git a/crates/dpp-vault/tests/seal_route.rs b/crates/dpp-vault/tests/seal_route.rs index a4fe12f..3b915f5 100644 --- a/crates/dpp-vault/tests/seal_route.rs +++ b/crates/dpp-vault/tests/seal_route.rs @@ -175,3 +175,65 @@ async fn the_route_requires_authentication() { .expect("request"); assert_eq!(resp.status(), 401); } + +/// The summary counts **passports**, not outbox rows, and this is the case that +/// makes the distinction load-bearing. +/// +/// `enqueue` runs after the publish commits, so a crash in that window leaves a +/// published passport with no row anywhere. An empty outbox is therefore fully +/// consistent with unsealed passports — and a summary built on row counts alone +/// would report all clear while the obligation went unmet. Seeding a published, +/// unsealed passport and no rows at all reproduces exactly that state. +#[tokio::test(flavor = "multi_thread")] +async fn the_summary_counts_unsealed_passports_not_outbox_rows() { + let pg = start_postgres().await; + let base = start_vault(pg.dal.clone()).await; + seed(&pg.dal, None, Some(JWS)).await; + let client = TestClient::new(&base, make_jwt(&op())); + + let resp = client.get("/api/v1/seal").await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + + assert_eq!( + body["unsealedPublished"], 1, + "a published passport with no seal must be counted, with or without a row" + ); + assert_eq!( + body["pending"], 0, + "no row was ever enqueued — this is the lost-enqueue state" + ); + assert_eq!(body["exhausted"], 0); + assert_eq!( + body["sealingConfigured"], true, + "the harness wires an outbox, as any node with a provider does" + ); +} + +/// A sealed passport is not counted, and the count is what a reader acts on. +#[tokio::test(flavor = "multi_thread")] +async fn a_sealed_passport_is_not_reported_as_unsealed() { + let pg = start_postgres().await; + let base = start_vault(pg.dal.clone()).await; + seed(&pg.dal, Some(envelope()), Some(JWS)).await; + let client = TestClient::new(&base, make_jwt(&op())); + + let resp = client.get("/api/v1/seal").await; + assert_eq!(resp.status(), 200); + let body: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(body["unsealedPublished"], 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn the_summary_route_requires_authentication() { + let pg = start_postgres().await; + let base = start_vault(pg.dal.clone()).await; + let client = reqwest::Client::new(); + + let resp = client + .get(format!("{base}/api/v1/seal")) + .send() + .await + .expect("request"); + assert_eq!(resp.status(), 401); +} diff --git a/docs/architecture/DESIGN-PATTERNS.md b/docs/architecture/DESIGN-PATTERNS.md index 5a0e79f..5ec1ff9 100644 --- a/docs/architecture/DESIGN-PATTERNS.md +++ b/docs/architecture/DESIGN-PATTERNS.md @@ -125,7 +125,7 @@ Infrastructure dependencies that are optional use a NoOp implementation: | Event bus | `NatsEventBus` | `NoOpEventBus` (discards events) | | Compliance | Wasm sector plugin | `PassthroughRegistry` (accepts all) | | EU Registry | `EuRegistrySync` (HTTP, when the Commission publishes) | `GhostRegistrySync` — but registration intent is **never lost**: it lives in the durable outbox regardless of adapter tier | -| Qualified seal | CSC/QTSP adapter (`dpp-seal`) | `GhostSeal` — clearly marked placeholder | +| Qualified seal | A `SealBackend` selected by `SEAL_PROVIDER` (`dpp-seal`): a hosted QTSP, or a local dev sealer that is real CMS and no legal weight | `GhostSeal` — clearly marked placeholder | **Why:** Self-hosted single-node deployments should work without NATS. The NoOp pattern means the code paths are identical — no `if nats_enabled { ... }` branches. The trait dispatch handles it. diff --git a/docs/architecture/OVERVIEW.md b/docs/architecture/OVERVIEW.md index 22cc8af..14c2e45 100644 --- a/docs/architecture/OVERVIEW.md +++ b/docs/architecture/OVERVIEW.md @@ -127,8 +127,10 @@ dpp-types <-- dpp-dal <-- dpp-vault <-- dpp-node dpp-common (event bus trait, telemetry) <-- dpp-vault, dpp-node dpp-plugin-host <-- dpp-node -dpp-seal (CSC/QTSP adapter scaffold — resolves to Ghost until a QTSP is - configured; a NODE_PROFILE=production node refuses to boot on it) +dpp-seal (eIDAS seal adapter over one SealBackend, chosen by SEAL_PROVIDER: + a hosted QTSP, a local development sealer, or Ghost when unset. + Only the first carries legal weight — a NODE_PROFILE=production + node refuses to boot on either of the others) dpp-factor-data (licensed-LCI store — ghost provider until a dataset licence is signed; ghost-derived results are marked dataset_id="ghost") ``` diff --git a/docs/ops/PRODUCTION-RUNBOOK.md b/docs/ops/PRODUCTION-RUNBOOK.md index 1744524..a0ce8ce 100644 --- a/docs/ops/PRODUCTION-RUNBOOK.md +++ b/docs/ops/PRODUCTION-RUNBOOK.md @@ -10,11 +10,13 @@ | Tier | Profile | What it honestly claims | Available | |---|---|---|---| | **T1 Pilot-grade** | default (`NODE_PROFILE` unset) | Full lifecycle, signed + verified passports, hash-chained audit, outbox-durable registry intent; **trust ports run Ghost and say so** in `/health.trust_mode` | **Now** | -| **T2 Sealed-grade** | `NODE_PROFILE=production` | Everything above + real qualified seals (QTSP via CSC) | After the seal adapter is wired | +| **T2 Sealed-grade** | `NODE_PROFILE=production` | Everything above + real qualified seals from a hosted QTSP | After a QTSP credential exists — the adapter is wired, the account is not | | **T3 Registry-grade** | `NODE_PROFILE=production` | + real EU registry registration | After the Commission publishes its registry spec | **Blocker A — deliberate:** `NODE_PROFILE=production` **refuses to boot** while seal/registry resolve to Ghost (the honesty invariant working as designed). So every deployment today is **T1 by definition**: run the default profile, point monitoring at `/health`, and make no sealed/registered claims. Do not weaken the guard to "get to production" — the guard *is* the product's credibility. +**`NODE_PROFILE=sandbox`** is the third profile, and it is a property of the *deployment*, not a tier a production node may quietly carry. It is a full node in every respect except that the authorities behind it are test ones: ghosts on required ports are a hard boot failure exactly as in production, but `Sandbox` tiers are accepted, so the environment can be exercised end to end without a production credential. Run it as its own environment — it is the closest rehearsal of production available, and keeping the two profiles apart is what stops a test certificate ever sealing a passport that claims to be real. A `production` node refuses a sandbox tier for that reason. + **Blocker B — operational:** engine `main` now pins **core 0.4.0, which is unpublished** (0.3.0 is the latest on crates.io). The compose `pull` and plain `--build` modes resolve crates.io and **will fail**. Until 0.4.0 is published: build with the local-core overlay (`--build` + `-f docker/docker-compose.local.yml`, i.e. `just up-local`) and record the image digest you deployed. **Before the first external operator deploy: publish core 0.4.0** — your own release rule (CI/release = crates.io) exists precisely so a deploy is reproducible from public sources. ---