Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -108,15 +108,15 @@ 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
# value today; the variable exists so adding a QTSP later is not a config
# 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.
Expand All @@ -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=
Expand All @@ -150,17 +150,17 @@ 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
#
# Submit payloads that fail our own validation instead of refusing them. Off
# 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
93 changes: 80 additions & 13 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,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
Expand All @@ -68,6 +69,44 @@ 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.

- **`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
Expand All @@ -76,15 +115,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,
Expand Down
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
```
Expand Down
Loading
Loading