Skip to content

feat(seal): selectable backends, a local sealer, and profile split - #106

Open
LKSNDRTMLKV wants to merge 14 commits into
mainfrom
feat/seal-backend-split
Open

feat(seal): selectable backends, a local sealer, and profile split#106
LKSNDRTMLKV wants to merge 14 commits into
mainfrom
feat/seal-backend-split

Conversation

@LKSNDRTMLKV

@LKSNDRTMLKV LKSNDRTMLKV commented Aug 13, 2026

Copy link
Copy Markdown
Member

Sealing stops being locked to one provider in the composition root, gains a development backend that works without a vendor account, and starts answering two questions it previously could not.

Closes #105, closes #107. Advances #96 — see the comment there for what remains (a second real provider, gated on vendor sandbox access, not on wiring).

What changed

Backends sit behind a SealBackend trait. QtspSealAdapter holds a Box<dyn SealBackend> and does nothing but forward the port's three calls and collapse SealError into DppError at the boundary. Each backend module constructs its own, so every wiring site reads QtspSealAdapter::new(...) and the adapter names none of them. 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.

Provider errors moved into the provider's module. EideasyError owns the variants that only mean something against one contract — the five-minute HMAC skew window, the rate-limit wording, the status field that can say ERROR inside a 200. SealError keeps Config, Transport, Unsupported and a Backend(String) for a failure only the backend can describe. Nothing outside the crate ever matched on the variants.

A local development backend (SEAL_PROVIDER=local). Real detached CMS SignedData under a generated P-256 key, persisted between runs. Not qualified and cannot become so — the certificate is on no EU Trusted List — which the node states structurally by resolving it to the Ghost trust tier, so production refuses to boot on it while the envelope still drains.

It verifies its own seals, and is the only backend that does. verify() is now the trait's default and it refuses; previously the refusal was an arm in the adapter's match that a new backend would silently have missed. The hosted backend keeps refusing, for independence rather than tooling: a verdict issued by the node that bought the seal attests nothing a relying party should accept. Neither objection applies to the local backend — its seals make no trust claim beyond "this key signed this digest".

Making that possible needed a format fix. The first implementation signed the digest directly with signedAttrs absent, leaving the sealed digest in neither the envelope nor the seal — and SealPort::verify is handed only the envelope. It was unverifiable by construction. The seal now carries contentType and messageDigest in signedAttrs and signs over their DER SET OF encoding per RFC 5652 §5.4, which is what CAdES requires and what makes the envelope self-checking.

The seal route reports staleness. sealedPayloadHash and coverage (current / superseded / unknown). The envelope records no preimage, but the outbox row that bought the seal does and is never deleted — so a passport re-published after sealing is detectable with a lookup and a string comparison, no AdES tooling. It is the node's record of what it asked to seal, not proof of what the CAdES covers; the validator's extracted digest is the cross-check. unknown is deliberately distinct from superseded — branding a backup-restored seal stale on a missing row is the same error in the opposite direction.

Breaking

NODE_PROFILE=production now refuses a Sandbox trust tier, not only a ghost, and NODE_PROFILE=sandbox is new. A production node asserts its passports are backed by real authorities; admitting a sandbox tier made that untrue. Migration note in the changelog.

Not in this PR

  • Independent validation of a qualified seal. That needs a validation service, not a library, and the port cannot hold the answer anyway — SealVerification models a three-valued result as a boolean (SealVerification models a three-valued result as a boolean dpp-core#131). The uncheckable-but-not-invalid case already shows up here, in miniature: a seal with no signedAttrs is refused with an explicit message rather than reported valid: false.
  • Anything exercised against a real provider. Every path is mock-verified; no QTSP credential exists.

Verification

just check green locally throughout; CI green including the Postgres and testcontainers tiers. The three new sealed_digest integration tests could not run locally (no Docker) and passed in CI.

@codacy-production

codacy-production Bot commented Aug 13, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 97 complexity · 17 duplication

Metric Results
Complexity 97
Duplication 17

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@LKSNDRTMLKV LKSNDRTMLKV changed the title feat(seal): local CMS backend, and separate sandbox from production feat(seal): selectable backends, a local sealer, and profile split Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Isolate the seal provider behind a SealBackend trait Add a local seal implementation for development

1 participant