Skip to content

Commit db2d831

Browse files
authored
feat(vault): name who declared the content a seal covers (#196)
1 parent 222327e commit db2d831

9 files changed

Lines changed: 375 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,29 @@ under the pre-1.0 conventions in [VERSIONING.md](docs/governance/VERSIONING.md):
1212

1313
### Added
1414

15+
- **The seal route names who declared the content it covers.**
16+
`GET /api/v1/dpp/{dppId}/seal` gains `declaredBy`, carrying the manufacturer
17+
and Annex III(k) operator identifier frozen at publish, a
18+
`responsibilityMayHaveTransferred` flag, and a note stating the distinction
19+
outright.
20+
21+
A seal proves a document came from whoever holds the certificate and says
22+
nothing about *scope*, so "we vouch for this content" and "we transmitted this
23+
intact" look identical. Every audience view strips the seal, which makes this
24+
the one surface where the two can be collapsed — and its readers being
25+
authenticated and technical is a reason to be more careful, not less.
26+
27+
**Only a completed handover sets the flag.** An initiated transfer nobody
28+
accepted has moved nothing, and reporting it would claim a transfer that may
29+
still be rejected. The names cannot simply be updated instead: they are frozen
30+
into the sealed bytes and the seal covers them, so a "current operator" field
31+
would be an unverifiable claim sitting beside a verifiable document. The flag
32+
says the answer is historical and points at where the current one lives.
33+
34+
A transfer-store failure now fails the read rather than defaulting the flag to
35+
`false` — that default is a positive claim that responsibility has not moved,
36+
which is worse than serving nothing.
37+
1538
- **The contract gate now checks documented error codes.** It compared schemas,
1639
numeric bounds, enum variants, query parameters, request bodies and route
1740
coverage — everything except what a route says goes *wrong*. A status code has
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
type: object
2+
description: |
3+
Who declared the content a seal covers, which is not who sealed it.
4+
5+
A seal proves a document came from whoever holds the certificate. It carries no
6+
statement about *scope*: "we vouch for this content" and "we transmitted this
7+
intact" look identical. A response that serves a seal and names no declaring
8+
party invites the reader to collapse the two, whatever anyone intended.
9+
10+
Every audience view strips the seal, so this is the only surface where that
11+
collapse is reachable — and its readers being authenticated and technical makes
12+
them more likely to build on the assumption, not less.
13+
required:
14+
- manufacturer
15+
- operatorIdentifier
16+
- responsibilityMayHaveTransferred
17+
- note
18+
properties:
19+
manufacturer:
20+
type: string
21+
description: The manufacturer named in the sealed passport, frozen at publish.
22+
example: TestCorp GmbH
23+
operatorIdentifier:
24+
type:
25+
- string
26+
- 'null'
27+
description: >-
28+
The Annex III(k) unique operator identifier recorded at publish. `null`
29+
means none was recorded — never that none applies.
30+
responsibilityMayHaveTransferred:
31+
type: boolean
32+
description: |
33+
True when the passport's transfer chain records a **completed** handover,
34+
so the party responsible now is not the one named above. An initiated
35+
handover nobody accepted has moved nothing and does not set this.
36+
37+
The names above are frozen into the sealed bytes and cannot be rewritten —
38+
a published passport's content is immutable and the seal covers it — so
39+
this flag is the only honest way to say the answer above is historical
40+
rather than current.
41+
note:
42+
type: string
43+
description: >-
44+
States the sealing/authorship distinction outright rather than leaving it
45+
to be inferred from field names, in the same spirit as `verification`.

api/components/schemas/SealResponse.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ description: >-
33
The eIDAS seal, plus what is needed to check it — and an explicit statement of
44
what this node did **not** check.
55
required:
6+
- declaredBy
67
- format
78
- sealValue
89
- sealedAt
@@ -12,6 +13,8 @@ required:
1213
- coverage
1314
- verification
1415
properties:
16+
declaredBy:
17+
$ref: ./SealDeclarer.yaml
1518
format:
1619
type: string
1720
description: AdES format of `sealValue`.

api/openapi.bundled.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5627,6 +5627,38 @@
56275627
}
56285628
}
56295629
},
5630+
"SealDeclarer": {
5631+
"type": "object",
5632+
"description": "Who declared the content a seal covers, which is not who sealed it.\n\nA seal proves a document came from whoever holds the certificate. It carries no\nstatement about *scope*: \"we vouch for this content\" and \"we transmitted this\nintact\" look identical. A response that serves a seal and names no declaring\nparty invites the reader to collapse the two, whatever anyone intended.\n\nEvery audience view strips the seal, so this is the only surface where that\ncollapse is reachable — and its readers being authenticated and technical makes\nthem more likely to build on the assumption, not less.\n",
5633+
"required": [
5634+
"manufacturer",
5635+
"operatorIdentifier",
5636+
"responsibilityMayHaveTransferred",
5637+
"note"
5638+
],
5639+
"properties": {
5640+
"manufacturer": {
5641+
"type": "string",
5642+
"description": "The manufacturer named in the sealed passport, frozen at publish.",
5643+
"example": "TestCorp GmbH"
5644+
},
5645+
"operatorIdentifier": {
5646+
"type": [
5647+
"string",
5648+
"null"
5649+
],
5650+
"description": "The Annex III(k) unique operator identifier recorded at publish. `null` means none was recorded — never that none applies."
5651+
},
5652+
"responsibilityMayHaveTransferred": {
5653+
"type": "boolean",
5654+
"description": "True when the passport's transfer chain records a **completed** handover,\nso the party responsible now is not the one named above. An initiated\nhandover nobody accepted has moved nothing and does not set this.\n\nThe names above are frozen into the sealed bytes and cannot be rewritten —\na published passport's content is immutable and the seal covers it — so\nthis flag is the only honest way to say the answer above is historical\nrather than current.\n"
5655+
},
5656+
"note": {
5657+
"type": "string",
5658+
"description": "States the sealing/authorship distinction outright rather than leaving it to be inferred from field names, in the same spirit as `verification`."
5659+
}
5660+
}
5661+
},
56305662
"Coverage": {
56315663
"type": "string",
56325664
"description": "Whether the stored seal covers the passport's current signature. Answered from this node's record of what it *asked* to be sealed — weaker than a validator's verdict, stronger than nothing: it cannot confirm the CAdES, but a passport re-published after sealing is knowable without any AdES tooling.",
@@ -5640,6 +5672,7 @@
56405672
"type": "object",
56415673
"description": "The eIDAS seal, plus what is needed to check it — and an explicit statement of what this node did **not** check.",
56425674
"required": [
5675+
"declaredBy",
56435676
"format",
56445677
"sealValue",
56455678
"sealedAt",
@@ -5650,6 +5683,9 @@
56505683
"verification"
56515684
],
56525685
"properties": {
5686+
"declaredBy": {
5687+
"$ref": "#/components/schemas/SealDeclarer"
5688+
},
56535689
"format": {
56545690
"type": "string",
56555691
"description": "AdES format of `sealValue`.",

api/openapi.bundled.yaml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4258,6 +4258,48 @@ components:
42584258
description: Rows that gave up after exhausting their retries.
42594259
sealingConfigured:
42604260
type: boolean
4261+
SealDeclarer:
4262+
type: object
4263+
description: |
4264+
Who declared the content a seal covers, which is not who sealed it.
4265+
4266+
A seal proves a document came from whoever holds the certificate. It carries no
4267+
statement about *scope*: "we vouch for this content" and "we transmitted this
4268+
intact" look identical. A response that serves a seal and names no declaring
4269+
party invites the reader to collapse the two, whatever anyone intended.
4270+
4271+
Every audience view strips the seal, so this is the only surface where that
4272+
collapse is reachable — and its readers being authenticated and technical makes
4273+
them more likely to build on the assumption, not less.
4274+
required:
4275+
- manufacturer
4276+
- operatorIdentifier
4277+
- responsibilityMayHaveTransferred
4278+
- note
4279+
properties:
4280+
manufacturer:
4281+
type: string
4282+
description: The manufacturer named in the sealed passport, frozen at publish.
4283+
example: TestCorp GmbH
4284+
operatorIdentifier:
4285+
type:
4286+
- string
4287+
- 'null'
4288+
description: The Annex III(k) unique operator identifier recorded at publish. `null` means none was recorded — never that none applies.
4289+
responsibilityMayHaveTransferred:
4290+
type: boolean
4291+
description: |
4292+
True when the passport's transfer chain records a **completed** handover,
4293+
so the party responsible now is not the one named above. An initiated
4294+
handover nobody accepted has moved nothing and does not set this.
4295+
4296+
The names above are frozen into the sealed bytes and cannot be rewritten —
4297+
a published passport's content is immutable and the seal covers it — so
4298+
this flag is the only honest way to say the answer above is historical
4299+
rather than current.
4300+
note:
4301+
type: string
4302+
description: States the sealing/authorship distinction outright rather than leaving it to be inferred from field names, in the same spirit as `verification`.
42614303
Coverage:
42624304
type: string
42634305
description: 'Whether the stored seal covers the passport''s current signature. Answered from this node''s record of what it *asked* to be sealed — weaker than a validator''s verdict, stronger than nothing: it cannot confirm the CAdES, but a passport re-published after sealing is knowable without any AdES tooling.'
@@ -4269,6 +4311,7 @@ components:
42694311
type: object
42704312
description: The eIDAS seal, plus what is needed to check it — and an explicit statement of what this node did **not** check.
42714313
required:
4314+
- declaredBy
42724315
- format
42734316
- sealValue
42744317
- sealedAt
@@ -4278,6 +4321,8 @@ components:
42784321
- coverage
42794322
- verification
42804323
properties:
4324+
declaredBy:
4325+
$ref: '#/components/schemas/SealDeclarer'
42814326
format:
42824327
type: string
42834328
description: AdES format of `sealValue`.

crates/dpp-node/tests/openapi_contract.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ fn object_cases() -> Vec<ObjectCase> {
396396
case!("OperatorScanStats", fixtures::operator_scan_stats());
397397
case!("DailyScanCount", fixtures::daily_scan_count());
398398
case!("SealResponse", fixtures::seal_response());
399+
case!("SealDeclarer", fixtures::seal_declarer());
399400
case!("SealSummaryResponse", fixtures::seal_summary_response());
400401
case!("InstalledPlugin", fixtures::installed_plugin());
401402
case!("WebhookSubscription", fixtures::webhook_subscription());
@@ -2166,7 +2167,7 @@ mod fixtures {
21662167
CurrentOperatorView, PassportRegistryView, RegistrationCounts, RegistrationView,
21672168
RegistryRollupView, TransferCounts, TransferView, VerificationView,
21682169
},
2169-
seal::{Coverage, SealResponse, SealSummaryResponse},
2170+
seal::{Coverage, SealDeclarer, SealResponse, SealSummaryResponse},
21702171
suspend::SuspendRequest,
21712172
transfer::TransferInitiateRequest,
21722173
validate::ValidateResponse,
@@ -2933,8 +2934,20 @@ mod fixtures {
29332934
}
29342935
}
29352936

2937+
pub fn seal_declarer() -> SealDeclarer {
2938+
SealDeclarer {
2939+
manufacturer: "TestCorp GmbH".into(),
2940+
operator_identifier: Some("LEI:529900T8BM49AURSDO55".into()),
2941+
responsibility_may_have_transferred: true,
2942+
// A `&'static str` constant on the type, like `verification` below;
2943+
// the fixture needs a value of the right shape for the key set.
2944+
note: "the seal attests to the sender, not the author",
2945+
}
2946+
}
2947+
29362948
pub fn seal_response() -> SealResponse {
29372949
SealResponse {
2950+
declared_by: seal_declarer(),
29382951
format: "CADES".into(),
29392952
seal_value: "MIIB...".into(),
29402953
sealed_at: ts(),

crates/dpp-vault/src/handlers/seal.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
//! carries everything an external validator needs and states plainly what has
1414
//! and has not been verified.
1515
//!
16+
//! It also names **who declared the content**, which is a different party from
17+
//! whoever sealed it. A seal says a document came from the certificate holder
18+
//! and nothing about scope, so serving one with no declarer beside it invites
19+
//! the reader to conclude the sealer authored what it covers. Since every
20+
//! audience view strips the seal, this is the only surface where that
21+
//! conclusion is reachable — see [`SealDeclarer`].
22+
//!
1623
//! It does answer one narrower question, because it can: **is this seal stale?**
1724
//! The envelope carries no preimage, but the outbox row that bought it does, and
1825
//! those rows are never deleted — so a passport re-published after sealing is
@@ -34,10 +41,50 @@ use crate::{middleware::auth::AuthContext, state::AppState};
3441

3542
use super::error::{internal_error, not_found_error, parse_passport_id};
3643

44+
/// Who declared the content a seal covers, which is not who sealed it.
45+
///
46+
/// A seal proves a document came from whoever holds the certificate. It carries
47+
/// no statement about *scope*: "we vouch for this content" and "we transmitted
48+
/// this intact" look identical. A response that serves a seal and names no
49+
/// declaring party invites the reader to collapse the two, whatever anyone
50+
/// intended.
51+
///
52+
/// Every audience view strips the seal, so this is the only surface where that
53+
/// collapse is reachable — and its readers being authenticated and technical
54+
/// makes them more likely to build on the assumption, not less.
55+
#[derive(Debug, Serialize)]
56+
#[serde(rename_all = "camelCase")]
57+
pub struct SealDeclarer {
58+
/// The manufacturer named in the sealed passport, frozen at publish.
59+
pub manufacturer: String,
60+
/// The Annex III(k) unique operator identifier recorded at publish, if one
61+
/// was. `null` means none was recorded — never that none applies.
62+
pub operator_identifier: Option<String>,
63+
/// True when this passport's transfer chain records a completed handover, so
64+
/// the party responsible **now** is not the one named above.
65+
///
66+
/// The names above are frozen into the sealed bytes and cannot be rewritten:
67+
/// a published passport's content is immutable, and the seal covers it. So
68+
/// this flag is the only honest way to say that the answer above is a
69+
/// historical fact rather than a current one.
70+
pub responsibility_may_have_transferred: bool,
71+
/// Stated rather than left to inference, in the same spirit as
72+
/// [`SealResponse::verification`].
73+
pub note: &'static str,
74+
}
75+
76+
const DECLARER_NOTE: &str = "the seal attests that this document came from the holder of the sealing certificate; it makes \
77+
no statement about who authored the content. `manufacturer` is the party that declared it, \
78+
frozen at publish. Where `responsibilityMayHaveTransferred` is true, the operator responsible \
79+
today is a different question — this node's transfer chain records what it was told, and the \
80+
EU registry holds the authoritative record between verified actors.";
81+
3782
/// The seal, plus what is needed to check it and what we did not check.
3883
#[derive(Debug, Serialize)]
3984
#[serde(rename_all = "camelCase")]
4085
pub struct SealResponse {
86+
/// Who declared the content, as distinct from who sealed it.
87+
pub declared_by: SealDeclarer,
4188
/// AdES format of `sealValue` — `CADES` for the eID Easy backend.
4289
pub format: String,
4390
/// Base64 detached CAdES (`.p7s`) as returned by the QTSP.
@@ -166,9 +213,35 @@ pub async fn seal_handler(
166213
};
167214
let coverage = coverage_of(sealed_payload_hash.as_deref(), &payload_hash);
168215

216+
// Has responsibility moved since this passport was sealed? Only a *completed*
217+
// handover counts: an initiated one that nobody accepted has moved nothing,
218+
// and reporting it would claim a transfer that may still be rejected. A node
219+
// with no transfer store configured records no handovers, so the honest
220+
// answer there is `false` rather than an error.
221+
//
222+
// A store that *errors*, though, fails the whole read. `false` is not a safe
223+
// default here — it is a positive claim that responsibility has not moved,
224+
// and serving it beside a seal on the strength of a failed query is the one
225+
// outcome worse than serving nothing. So the seal becomes unreadable while
226+
// the transfer store is down, deliberately.
227+
let responsibility_may_have_transferred = match state.service.transfer_store.as_ref() {
228+
Some(store) => match store.get_chain(passport_id).await {
229+
Ok(Some(chain)) => chain.transfer_count() > 0,
230+
Ok(None) => false,
231+
Err(e) => return internal_error(e),
232+
},
233+
None => false,
234+
};
235+
169236
(
170237
StatusCode::OK,
171238
Json(SealResponse {
239+
declared_by: SealDeclarer {
240+
manufacturer: passport.manufacturer.name.clone(),
241+
operator_identifier: passport.operator_identifier.clone(),
242+
responsibility_may_have_transferred,
243+
note: DECLARER_NOTE,
244+
},
172245
format: serde_json::to_value(&seal.format)
173246
.ok()
174247
.and_then(|v| v.as_str().map(ToOwned::to_owned))

crates/dpp-vault/tests/helpers/mod.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use base64::Engine;
1515
use dpp_dal::pg::{
1616
PgApiKeyRepo, PgAuditRepo, PgDal, PgEvidenceDossierRepo, PgOperatorConfigRepo, PgPassportRepo,
1717
PgRegistryIdentityRepo, PgRegistrySyncRepo, PgRegistryTransferRepo, PgScanTelemetryRepo,
18-
PgSealOutboxRepo, PgWebhookRepo,
18+
PgSealOutboxRepo, PgTransferRepo, PgWebhookRepo,
1919
};
2020
use dpp_domain::{
2121
DppError, GhostArchive, GhostRegistrySync, PassthroughRegistry,
@@ -291,6 +291,12 @@ async fn start_vault_with_identity(
291291
// selected wires this, so a harness without it reports the sealing
292292
// surface as unconfigured and cannot exercise it at all.
293293
.with_seal_outbox(Arc::new(PgSealOutboxRepo::new(dal.clone())))
294+
// Same reasoning again, and it had already cost something: without a
295+
// transfer store the seal route's `responsibilityMayHaveTransferred`
296+
// took its `None => false` branch, so a test asserting the flag was
297+
// `false` passed because nothing *could* be recorded rather than
298+
// because nothing was.
299+
.with_transfer_store(Arc::new(PgTransferRepo::new(dal.clone())))
294300
.with_evidence_store(Arc::new(PgEvidenceDossierRepo::new(dal.clone()))),
295301
);
296302
let operator_service = Arc::new(OperatorService::new(operator_repo));

0 commit comments

Comments
 (0)