@typedstandards/verify-core 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/dist/attestation.d.ts +19 -1
- package/dist/attestation.js +23 -4
- package/dist/lifecycle.d.ts +164 -14
- package/dist/lifecycle.js +251 -24
- package/dist/verify.d.ts +9 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,80 @@ references are to `npstorey/civic-ai-tools-website` (#119 is the offline-crypto
|
|
|
6
6
|
hardening arc; #116 is the standalone-verifier arc this package was extracted
|
|
7
7
|
in).
|
|
8
8
|
|
|
9
|
+
## 0.13.0 — 2026-09-27
|
|
10
|
+
|
|
11
|
+
The core-minors release (typedstandards#113, shipped in #114, #115 and #118). **A minor bump** —
|
|
12
|
+
`LIFECYCLE_STATUSES` gains `superseded`, so a consumer with an exhaustive `switch` or `Record` over
|
|
13
|
+
`LifecycleStatus` needs the new case, and `verifyLifecycleChain` now moves status only on a node
|
|
14
|
+
whose key is bound to the signer it names and that carries its required fields.
|
|
15
|
+
|
|
16
|
+
- **A lifecycle attestation moves status only when its signing key is bound to the signer it names
|
|
17
|
+
(typedstandards#113).** `verifyLifecycleChain` takes an optional fourth argument, a
|
|
18
|
+
`LifecycleKeyBinding` (`targetPublicKey`, `registry`, `registryProvenance`), and a carried node
|
|
19
|
+
signer-matches only when its `signer.identifier` equals the target's and its key is bound:
|
|
20
|
+
- a key-derived identifier (`did:key:`) binds by derivation from the node's own public key, the rule
|
|
21
|
+
check #14 applies to a record;
|
|
22
|
+
- any other identifier binds when the node is signed by the target record's own key
|
|
23
|
+
(`targetPublicKey`), or when a registry fetched from its declared URL (`registryProvenance:
|
|
24
|
+
'declared-url'`) lists the node's `(kid, publicKey)` under that identifier, read by the rules of
|
|
25
|
+
checks #5 and #14. A registry carried in a bundle, or one with no stated provenance, never binds.
|
|
26
|
+
|
|
27
|
+
A node that is not bound stays in `chain` and does not move status. A caller that passes no binding
|
|
28
|
+
material gets a status moved only by `did:key` signers. `LifecycleAttestationView` gains an optional
|
|
29
|
+
`keyBound`, set by `verifyLifecycleChain`, and `CarriedLifecycleNode.signature` gains an optional
|
|
30
|
+
`kid`. `resolveLifecycleFromChain` is unchanged.
|
|
31
|
+
- **A carried lifecycle node that lacks a required payload field moves no status (typedstandards#113,
|
|
32
|
+
spec §8.12.3).** In `verifyLifecycleChain`, a node that lacks a field `ATTESTATION_REQUIRED_FIELDS`
|
|
33
|
+
lists for its sub-type (absent, null or the empty string: a `withdraws` with no `reason`, a
|
|
34
|
+
`reinstates` with no `priorWithdrawalNodeId`, a `supersedes` or `revises` with no
|
|
35
|
+
`successorNodeId`) stays in `chain` with `signerMatchesTarget` false, whoever signed it, and does not
|
|
36
|
+
move the status. `LifecycleAttestationView` gains an optional `missingFields`, set by
|
|
37
|
+
`verifyLifecycleChain` on every view: the fields the node lacks, by name, empty when it lacks none.
|
|
38
|
+
`resolveLifecycleFromChain` and `checkAttestationNode` are unchanged.
|
|
39
|
+
- **On typedstandards.org** (the site's verifier; not part of this package's API): the carried lifecycle
|
|
40
|
+
chain is resolved with the record's own signing key, and with the trust registry only when it was
|
|
41
|
+
fetched from the record's declared URL. A carried lifecycle event that names the record's signer and
|
|
42
|
+
lacks a required payload field adds one #10 line at `attention`, whose detail names the field, and
|
|
43
|
+
the #10 row lists the event under the fields it lacks. A third party's event that lacks one reads as
|
|
44
|
+
any third-party event does and adds no line (the owner's correction at the P5-fix gate, per G0 D6 as
|
|
45
|
+
corrected).
|
|
46
|
+
- **`superseded` is a lifecycle status (typedstandards#113, G0 D2).** `LIFECYCLE_STATUSES` is
|
|
47
|
+
`active`, `withdrawn`, `superseded`. `resolveLifecycleFromChain` reads the status from the latest
|
|
48
|
+
signer-matched node among `withdraws`, `reinstates` and `supersedes`: a `supersedes` reads
|
|
49
|
+
`superseded`; a `reinstates` reads `superseded` when a signer-matched `supersedes` precedes it and
|
|
50
|
+
`active` otherwise; a later `withdraws` reads `withdrawn`. `revises` is surfaced in `chain` and never
|
|
51
|
+
moves the status. `LifecycleResolution` gains `supersededAt` and `successorNodeId`, from the latest
|
|
52
|
+
counting `supersedes`. For a chain of `withdraws` and `reinstates` only, the output is unchanged.
|
|
53
|
+
`resolveLifecycleFromLegacyColumns` never derives `superseded`.
|
|
54
|
+
- `LifecycleAttestationView` gains optional `successorNodeId` and `namesTarget` (set by
|
|
55
|
+
`verifyLifecycleChain`: the node's `signer.identifier` equals the target's, bound or not).
|
|
56
|
+
- `CommitmentLifecycleState.status` widens to `'superseded'`, with optional `supersededAt` and
|
|
57
|
+
`successorNodeId`. `verifyRecord`'s STATE path still reads only the withdrawal columns.
|
|
58
|
+
- New constants `ATTESTATION_SUPERSEDES`, `ATTESTATION_REVISES`, `ATTESTATION_CORROBORATES`,
|
|
59
|
+
`ATTESTATION_CONTRADICTS`, and the lists `LIFECYCLE_STATUS_ATTESTATION_TYPES` (withdraws,
|
|
60
|
+
reinstates, supersedes) and `LIFECYCLE_CHAIN_ATTESTATION_TYPES` (those and revises).
|
|
61
|
+
`LifecycleAttestationType` and `LIFECYCLE_ATTESTATION_TYPES` stay `withdraws` and `reinstates`,
|
|
62
|
+
the withdrawal/reinstatement pair. produce-core also emits `supersedes` and `revises`, so they are
|
|
63
|
+
not the set it emits, and no verify-core check reads them.
|
|
64
|
+
- **A per-node authorization check, `checkAttestationNode` (typedstandards#113, G0 D6 and D4).** For
|
|
65
|
+
`withdraws`, `reinstates`, `supersedes`, `revises`, `corroborates` and `contradicts`, in order:
|
|
66
|
+
integrity and signature; the key binding, by `verifyLifecycleChain`'s rule; then the sub-type's
|
|
67
|
+
authorization rule (spec §8.12.3). Publisher-only (the four lifecycle sub-types) runs when the
|
|
68
|
+
attested record's signer and signing key are supplied, and otherwise reads `not_checked`; a node
|
|
69
|
+
naming another signer reads `other_signer`, and one naming the record's signer under a key not bound
|
|
70
|
+
to it reads `publisher_key_unbound`. Any-with-binding (`corroborates`, `contradicts`) needs the key
|
|
71
|
+
bound to the node's own identifier (a `did:key` by derivation; any other identifier only by a
|
|
72
|
+
registry fetched from its declared URL) and a `signer.bindingTier` in `BINDING_TIERS`
|
|
73
|
+
(`pseudonymous`, `oauth`, `orcid`, `did-web`, `notarized`, `platform`). The result names each
|
|
74
|
+
required §8.12.1 payload field the node lacks, in `missingFields`; it moves no status. New exports:
|
|
75
|
+
`checkAttestationNode`, `ATTESTATION_AUTHORIZATION_RULES`, `ATTESTATION_REQUIRED_FIELDS`,
|
|
76
|
+
`ATTESTATION_AUTHORIZATION_STATUSES`, `BINDING_TIERS`, and the types
|
|
77
|
+
`AttestationAuthorizationRule`, `AttestationAuthorizationStatus`, `AttestationCheckContext`,
|
|
78
|
+
`AttestationCheck`.
|
|
79
|
+
- **On typedstandards.org:** `superseded` reads `normal` ("Superseded by the publisher") and the #10
|
|
80
|
+
row names the successor. A carried lifecycle event that names the record's signer, under a key not
|
|
81
|
+
bound to it, adds one #10 line at `attention`; a third party's event adds nothing.
|
|
82
|
+
|
|
9
83
|
## 0.12.0 — 2026-09-24
|
|
10
84
|
|
|
11
85
|
The certificate-chain split (typedstandards#100, shipped in #106). **A minor bump** — `ChainFailReason`
|
package/dist/attestation.d.ts
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
export declare const ATTESTATION_WITHDRAWS = "attestation/withdraws/v1";
|
|
2
2
|
export declare const ATTESTATION_REINSTATES = "attestation/reinstates/v1";
|
|
3
|
+
export declare const ATTESTATION_SUPERSEDES = "attestation/supersedes/v1";
|
|
4
|
+
export declare const ATTESTATION_REVISES = "attestation/revises/v1";
|
|
5
|
+
export declare const ATTESTATION_CORROBORATES = "attestation/corroborates/v1";
|
|
6
|
+
export declare const ATTESTATION_CONTRADICTS = "attestation/contradicts/v1";
|
|
7
|
+
/** The withdrawal/reinstatement pair: the two lifecycle sub-types the legacy
|
|
8
|
+
* `withdrawnAt` / `reinstatedAt` columns also record (spec §8.10.4). produce-core's
|
|
9
|
+
* `EmittableAttestationType` includes this union beside the other sub-types it
|
|
10
|
+
* emits, `supersedes` and `revises` among them, so it is not the set produce-core
|
|
11
|
+
* emits. No verify-core check reads it: the lifecycle reads
|
|
12
|
+
* `LIFECYCLE_STATUS_ATTESTATION_TYPES` and `LIFECYCLE_CHAIN_ATTESTATION_TYPES`. */
|
|
3
13
|
export type LifecycleAttestationType = typeof ATTESTATION_WITHDRAWS | typeof ATTESTATION_REINSTATES;
|
|
4
|
-
/** The
|
|
14
|
+
/** The values of `LifecycleAttestationType`, the withdrawal/reinstatement pair. Not
|
|
15
|
+
* the set produce-core emits, and not read by any verify-core check (see
|
|
16
|
+
* `LifecycleAttestationType`). */
|
|
5
17
|
export declare const LIFECYCLE_ATTESTATION_TYPES: readonly string[];
|
|
18
|
+
/** The sub-types that move a record's lifecycle status (spec §8.10.6: `active`,
|
|
19
|
+
* `withdrawn`, `superseded`). `revises` and `publishes` never do. */
|
|
20
|
+
export declare const LIFECYCLE_STATUS_ATTESTATION_TYPES: readonly string[];
|
|
21
|
+
/** The publisher-only lifecycle sub-types surfaced in a record's lifecycle chain:
|
|
22
|
+
* the status-moving three, and `revises`, which is shown but moves nothing. */
|
|
23
|
+
export declare const LIFECYCLE_CHAIN_ATTESTATION_TYPES: readonly string[];
|
package/dist/attestation.js
CHANGED
|
@@ -1,12 +1,31 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Attestation sub-type URIs (spec §8.10, §8.12.1) — browser-safe constants. The
|
|
2
|
+
// server `attestation.ts` builder (which mints nodes and so needs Node's
|
|
3
|
+
// `crypto.randomUUID`) re-exports these from verify-core, keeping a single
|
|
4
4
|
// definition of the URIs the verify side dispatches on. verify-core never builds
|
|
5
5
|
// nodes — it only verifies and orders them — so it carries the constants alone.
|
|
6
6
|
export const ATTESTATION_WITHDRAWS = 'attestation/withdraws/v1';
|
|
7
7
|
export const ATTESTATION_REINSTATES = 'attestation/reinstates/v1';
|
|
8
|
-
|
|
8
|
+
export const ATTESTATION_SUPERSEDES = 'attestation/supersedes/v1';
|
|
9
|
+
export const ATTESTATION_REVISES = 'attestation/revises/v1';
|
|
10
|
+
export const ATTESTATION_CORROBORATES = 'attestation/corroborates/v1';
|
|
11
|
+
export const ATTESTATION_CONTRADICTS = 'attestation/contradicts/v1';
|
|
12
|
+
/** The values of `LifecycleAttestationType`, the withdrawal/reinstatement pair. Not
|
|
13
|
+
* the set produce-core emits, and not read by any verify-core check (see
|
|
14
|
+
* `LifecycleAttestationType`). */
|
|
9
15
|
export const LIFECYCLE_ATTESTATION_TYPES = [
|
|
10
16
|
ATTESTATION_WITHDRAWS,
|
|
11
17
|
ATTESTATION_REINSTATES,
|
|
12
18
|
];
|
|
19
|
+
/** The sub-types that move a record's lifecycle status (spec §8.10.6: `active`,
|
|
20
|
+
* `withdrawn`, `superseded`). `revises` and `publishes` never do. */
|
|
21
|
+
export const LIFECYCLE_STATUS_ATTESTATION_TYPES = [
|
|
22
|
+
ATTESTATION_WITHDRAWS,
|
|
23
|
+
ATTESTATION_REINSTATES,
|
|
24
|
+
ATTESTATION_SUPERSEDES,
|
|
25
|
+
];
|
|
26
|
+
/** The publisher-only lifecycle sub-types surfaced in a record's lifecycle chain:
|
|
27
|
+
* the status-moving three, and `revises`, which is shown but moves nothing. */
|
|
28
|
+
export const LIFECYCLE_CHAIN_ATTESTATION_TYPES = [
|
|
29
|
+
...LIFECYCLE_STATUS_ATTESTATION_TYPES,
|
|
30
|
+
ATTESTATION_REVISES,
|
|
31
|
+
];
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { SignerIdentity } from './types.ts';
|
|
2
|
-
|
|
2
|
+
import type { TrustRegistry, TrustRegistryProvenance } from './trust-registry.ts';
|
|
3
|
+
/** Lifecycle status (spec §8.10.6). Only the attestation chain derives
|
|
4
|
+
* `superseded`; the legacy columns (§8.10.4) know no supersession. */
|
|
5
|
+
export declare const LIFECYCLE_STATUSES: readonly ["active", "withdrawn", "superseded"];
|
|
3
6
|
export type LifecycleStatus = (typeof LIFECYCLE_STATUSES)[number];
|
|
4
7
|
/** Which representation determined the lifecycle status. `none` = never
|
|
5
8
|
* withdrawn, no attestations and no legacy columns. */
|
|
@@ -15,6 +18,8 @@ export interface LifecycleAttestationView {
|
|
|
15
18
|
reason?: string;
|
|
16
19
|
effectiveAt?: string;
|
|
17
20
|
priorWithdrawalNodeId?: string;
|
|
21
|
+
/** `supersedes` / `revises`: the successor node's id. */
|
|
22
|
+
successorNodeId?: string;
|
|
18
23
|
/** Ed25519ph signature over the recomputed nodeId; null when unsigned. */
|
|
19
24
|
signatureValid: boolean | null;
|
|
20
25
|
/** Recomputed envelope hash equals the stored nodeId (integrity). */
|
|
@@ -25,9 +30,24 @@ export interface LifecycleAttestationView {
|
|
|
25
30
|
/** Rekor inclusion-proof entry present (presence only; per-attestation Rekor
|
|
26
31
|
* cryptographic verification is a follow-up). */
|
|
27
32
|
hasRekor: boolean;
|
|
28
|
-
/**
|
|
29
|
-
* matches the target content node's signer.identifier.
|
|
33
|
+
/** Whether the node counts toward the status (§8.12.3). The attestation's
|
|
34
|
+
* signer.identifier matches the target content node's signer.identifier. On a
|
|
35
|
+
* view built by `verifyLifecycleChain`, it also requires `keyBound`, and that the
|
|
36
|
+
* node carries every payload field §8.12.1 requires of its sub-type
|
|
37
|
+
* (`missingFields` empty): §8.12.3's conformance covers the payload fields. */
|
|
30
38
|
signerMatchesTarget: boolean;
|
|
39
|
+
/** Set by `verifyLifecycleChain`: the node's signing key is bound to the
|
|
40
|
+
* signer it names (see `verifyLifecycleChain`). Absent on views built
|
|
41
|
+
* elsewhere. */
|
|
42
|
+
keyBound?: boolean;
|
|
43
|
+
/** Set by `verifyLifecycleChain`: the node's `signer.identifier` equals the
|
|
44
|
+
* target's, whether or not its key is bound. Absent on views built elsewhere. */
|
|
45
|
+
namesTarget?: boolean;
|
|
46
|
+
/** Set by `verifyLifecycleChain`: each payload field §8.12.1 requires of the
|
|
47
|
+
* node's sub-type (`ATTESTATION_REQUIRED_FIELDS`) that the node lacks (absent,
|
|
48
|
+
* null or the empty string), by name; empty when it lacks none. A node that
|
|
49
|
+
* lacks one does not count toward the status. Absent on views built elsewhere. */
|
|
50
|
+
missingFields?: string[];
|
|
31
51
|
}
|
|
32
52
|
export interface LifecycleResolution {
|
|
33
53
|
status: LifecycleStatus;
|
|
@@ -40,6 +60,10 @@ export interface LifecycleResolution {
|
|
|
40
60
|
withdrawnReason?: string;
|
|
41
61
|
reinstatedAt?: string;
|
|
42
62
|
reinstatedReason?: string;
|
|
63
|
+
/** The latest counting `supersedes`: its envelope timestamp. */
|
|
64
|
+
supersededAt?: string;
|
|
65
|
+
/** The latest counting `supersedes`: its `successorNodeId`. */
|
|
66
|
+
successorNodeId?: string;
|
|
43
67
|
}
|
|
44
68
|
export interface AttestationVerifyResult {
|
|
45
69
|
/** The recomputed envelope hash (= nodeId by construction). */
|
|
@@ -62,18 +86,33 @@ export declare function verifyAttestationNode(node: Record<string, unknown>, sto
|
|
|
62
86
|
} | null): AttestationVerifyResult;
|
|
63
87
|
/**
|
|
64
88
|
* Check #10 (chain path) — derive lifecycle status from a set of verified
|
|
65
|
-
* attestation views (spec §8.10.1, §8.10.3
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
89
|
+
* attestation views (spec §8.10.1, §8.10.3, §8.10.6; typedstandards#113 G0 D2).
|
|
90
|
+
*
|
|
91
|
+
* The chain holds the publisher-only lifecycle sub-types
|
|
92
|
+
* (`LIFECYCLE_CHAIN_ATTESTATION_TYPES`: withdraws, reinstates, supersedes,
|
|
93
|
+
* revises), ordered by envelope timestamp, ties by nodeId. The status is read
|
|
94
|
+
* from the latest signer-matched node among withdraws, reinstates and
|
|
95
|
+
* supersedes (`LIFECYCLE_STATUS_ATTESTATION_TYPES`):
|
|
96
|
+
* - `withdraws` → withdrawn;
|
|
97
|
+
* - `supersedes` → superseded;
|
|
98
|
+
* - `reinstates` → superseded when a signer-matched `supersedes` precedes it in
|
|
99
|
+
* chain order, active otherwise.
|
|
100
|
+
* `revises` is surfaced in the chain and never moves the status (§8.10.5: no
|
|
101
|
+
* deprecation signal). Non-signer-matched attestations are kept in the surfaced
|
|
102
|
+
* chain for transparency but do NOT move the status (retention asymmetry,
|
|
103
|
+
* §8.10.3). The latest signer-matched `supersedes` names the successor and the
|
|
104
|
+
* supersession time.
|
|
105
|
+
*
|
|
106
|
+
* For a chain of withdraws and reinstates only, the output is what it was before
|
|
107
|
+
* `superseded` existed.
|
|
70
108
|
*/
|
|
71
109
|
export declare function resolveLifecycleFromChain(views: LifecycleAttestationView[]): LifecycleResolution;
|
|
72
110
|
/**
|
|
73
111
|
* Check #10 (legacy fallback) — derive lifecycle status from the pre-PR3
|
|
74
112
|
* `withdrawnAt` / `reinstatedAt` columns (spec §8.10.4). Used when a content node
|
|
75
113
|
* has no attestation envelopes. Withdrawn iff `withdrawnAt` is set and
|
|
76
|
-
* `reinstatedAt` is not.
|
|
114
|
+
* `reinstatedAt` is not. It never derives `superseded`: the columns record no
|
|
115
|
+
* supersession.
|
|
77
116
|
*/
|
|
78
117
|
export declare function resolveLifecycleFromLegacyColumns(columns: {
|
|
79
118
|
withdrawnAt?: string | null;
|
|
@@ -94,11 +133,24 @@ export interface CarriedLifecycleNode {
|
|
|
94
133
|
signature?: string;
|
|
95
134
|
publicKey?: string;
|
|
96
135
|
algorithm?: string;
|
|
136
|
+
kid?: string;
|
|
97
137
|
} | null;
|
|
98
138
|
/** Presence flags (surfaced; per-attestation TSA/Rekor depth is a follow-up). */
|
|
99
139
|
hasTimestamp?: boolean;
|
|
100
140
|
hasRekor?: boolean;
|
|
101
141
|
}
|
|
142
|
+
/** What a caller holds that can bind a lifecycle attestation's signing key to
|
|
143
|
+
* the signer it names (see `verifyLifecycleChain`). */
|
|
144
|
+
export interface LifecycleKeyBinding {
|
|
145
|
+
/** The target record's own signing key (the base64 SPKI `publicKey` of its
|
|
146
|
+
* signature envelope). */
|
|
147
|
+
targetPublicKey?: string;
|
|
148
|
+
/** A parsed trust registry. */
|
|
149
|
+
registry?: TrustRegistry;
|
|
150
|
+
/** Where `registry` came from. Only `declared-url` can bind a key; absent is
|
|
151
|
+
* treated as `bundle`. */
|
|
152
|
+
registryProvenance?: TrustRegistryProvenance;
|
|
153
|
+
}
|
|
102
154
|
/**
|
|
103
155
|
* Resolve lifecycle (#10) INDEPENDENTLY from carried signed attestation nodes —
|
|
104
156
|
* the browser/offline path that reaches `source: 'attestation-chain'` with no
|
|
@@ -112,9 +164,107 @@ export interface CarriedLifecycleNode {
|
|
|
112
164
|
* - INTEGRITY: the recomputed envelope hash MUST equal the stored `nodeId`.
|
|
113
165
|
* - SIGNATURE: the Ed25519(ph) signature MUST verify over that hash.
|
|
114
166
|
* A node failing any of these is EXCLUDED (a forged/tampered/misdirected transition
|
|
115
|
-
* cannot move the status).
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
167
|
+
* cannot move the status). A surviving node is signer-matched only when its
|
|
168
|
+
* `signer.identifier` equals the target's AND its signing key is bound to that
|
|
169
|
+
* identifier (`keyBound`), by what `binding` supplies:
|
|
170
|
+
* - a key-derived identifier (`did:key:`) binds by derivation from the node's own
|
|
171
|
+
* public key, the rule check #14 applies to a record;
|
|
172
|
+
* - any other identifier binds by `binding.targetPublicKey` (the node is signed by
|
|
173
|
+
* the target record's own key), or by `binding.registry` when
|
|
174
|
+
* `binding.registryProvenance` is `declared-url` and it lists the node's
|
|
175
|
+
* `(kid, publicKey)` under that identifier, read by the rules of checks #5 and
|
|
176
|
+
* #14. A registry carried in a bundle, or with no stated provenance, never binds.
|
|
177
|
+
* Each view records `namesTarget` (the identifiers are equal) beside `keyBound`, so a
|
|
178
|
+
* reader can tell a third party's node from one that names the publisher under a key
|
|
179
|
+
* not bound to it.
|
|
180
|
+
* A surviving node that lacks a payload field §8.12.1 requires of its sub-type
|
|
181
|
+
* (§8.12.3; `ATTESTATION_REQUIRED_FIELDS`, absent, null or the empty string) is not
|
|
182
|
+
* signer-matched either, whoever signed it: it stays in the chain, names the fields
|
|
183
|
+
* in `missingFields`, and does not move the status.
|
|
184
|
+
* Surviving nodes go to `resolveLifecycleFromChain`, which applies the §8.10.3
|
|
185
|
+
* retention asymmetry (a valid but non-signer-matched attestation is surfaced in the
|
|
186
|
+
* chain yet does NOT move the publisher's status).
|
|
187
|
+
*/
|
|
188
|
+
export declare function verifyLifecycleChain(carried: CarriedLifecycleNode[], contentNodeId: string, targetSignerIdentifier: string, binding?: LifecycleKeyBinding): LifecycleResolution;
|
|
189
|
+
/** A sub-type's authorization rule (spec §8.12.1). */
|
|
190
|
+
export type AttestationAuthorizationRule = 'publisher-only' | 'any-with-binding';
|
|
191
|
+
/** The rule of each sub-type `checkAttestationNode` checks. `publishes`,
|
|
192
|
+
* `locatedAt` and the specific-role-required sub-types have none here. */
|
|
193
|
+
export declare const ATTESTATION_AUTHORIZATION_RULES: Readonly<Record<string, AttestationAuthorizationRule>>;
|
|
194
|
+
/** Each checked sub-type's required payload fields (spec §8.12.1), beyond the
|
|
195
|
+
* structural primitive. Optional fields (`effectiveAt`, a reinstatement's
|
|
196
|
+
* `reason`, `reasoning`) are not listed. */
|
|
197
|
+
export declare const ATTESTATION_REQUIRED_FIELDS: Readonly<Record<string, readonly string[]>>;
|
|
198
|
+
/** The `signer.bindingTier` values an any-with-binding node may carry: the §8.5
|
|
199
|
+
* graded identity ladder, and `platform` (spec §8.1.1). */
|
|
200
|
+
export declare const BINDING_TIERS: readonly ["pseudonymous", "oauth", "orcid", "did-web", "notarized", "platform"];
|
|
201
|
+
/**
|
|
202
|
+
* What `checkAttestationNode` reads for a node, first failure first:
|
|
203
|
+
* - `node_id_mismatch` — the recomputed envelope hash is not the stored nodeId;
|
|
204
|
+
* - `signature_invalid` — the signature does not verify over it;
|
|
205
|
+
* - `unsigned` — the node carries no signature, so nothing can be bound;
|
|
206
|
+
* - `not_checked` — publisher-only with no attested record supplied, or a
|
|
207
|
+
* sub-type this check has no rule for;
|
|
208
|
+
* - `other_signer` — publisher-only: the node names a signer other than the
|
|
209
|
+
* attested record's, a third party's event;
|
|
210
|
+
* - `publisher_key_unbound` — publisher-only: the node names the attested
|
|
211
|
+
* record's signer, but its signing key is not bound to that signer;
|
|
212
|
+
* - `key_unbound` — any-with-binding: the signing key is not bound to the signer
|
|
213
|
+
* the node names;
|
|
214
|
+
* - `binding_tier_off_ladder` — any-with-binding: `signer.bindingTier` is not in
|
|
215
|
+
* `BINDING_TIERS`;
|
|
216
|
+
* - `authorized` — the sub-type's rule holds.
|
|
217
|
+
*/
|
|
218
|
+
export declare const ATTESTATION_AUTHORIZATION_STATUSES: readonly ["authorized", "not_checked", "other_signer", "publisher_key_unbound", "key_unbound", "binding_tier_off_ladder", "unsigned", "signature_invalid", "node_id_mismatch"];
|
|
219
|
+
export type AttestationAuthorizationStatus = (typeof ATTESTATION_AUTHORIZATION_STATUSES)[number];
|
|
220
|
+
/** What a caller holds for `checkAttestationNode`. */
|
|
221
|
+
export interface AttestationCheckContext {
|
|
222
|
+
/** The attested record: its `signer.identifier`, and the base64 SPKI
|
|
223
|
+
* `publicKey` of its signature envelope. The publisher-only rule runs only
|
|
224
|
+
* when this is supplied. */
|
|
225
|
+
target?: {
|
|
226
|
+
signerIdentifier: string;
|
|
227
|
+
publicKey: string;
|
|
228
|
+
};
|
|
229
|
+
/** A parsed trust registry that may bind the node's key to the identifier it
|
|
230
|
+
* names. */
|
|
231
|
+
registry?: TrustRegistry;
|
|
232
|
+
/** Where `registry` came from. Only `declared-url` can bind a key; absent is
|
|
233
|
+
* treated as `bundle`. */
|
|
234
|
+
registryProvenance?: TrustRegistryProvenance;
|
|
235
|
+
}
|
|
236
|
+
export interface AttestationCheck {
|
|
237
|
+
/** The recomputed envelope hash. */
|
|
238
|
+
nodeId: string;
|
|
239
|
+
type: string;
|
|
240
|
+
/** The sub-type's rule; null for a sub-type this check has none for. */
|
|
241
|
+
rule: AttestationAuthorizationRule | null;
|
|
242
|
+
nodeIdMatches: boolean;
|
|
243
|
+
signatureValid: boolean | null;
|
|
244
|
+
/** The signing key is bound to the signer the node names (see `status`). */
|
|
245
|
+
keyBound: boolean;
|
|
246
|
+
status: AttestationAuthorizationStatus;
|
|
247
|
+
/** Each required §8.12.1 payload field the node lacks (absent, null or the
|
|
248
|
+
* empty string), by name. Reported only; it does not change `status`. */
|
|
249
|
+
missingFields: string[];
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Check one `attestation/*` node on its own (spec §8.12.3), for `withdraws`,
|
|
253
|
+
* `reinstates`, `supersedes`, `revises`, `corroborates` and `contradicts`, in
|
|
254
|
+
* order:
|
|
255
|
+
* 1. integrity and signature, as `verifyAttestationNode` reads them;
|
|
256
|
+
* 2. the key binding, by the rule `verifyLifecycleChain` applies: a key-derived
|
|
257
|
+
* identifier binds by derivation; any other identifier binds by a registry
|
|
258
|
+
* fetched from its declared URL, and, for a publisher-only node only, by the
|
|
259
|
+
* attested record's own signing key;
|
|
260
|
+
* 3. the sub-type's authorization rule:
|
|
261
|
+
* - publisher-only (the four lifecycle sub-types) runs when `context.target`
|
|
262
|
+
* is supplied, and otherwise reads `not_checked`. A node naming another
|
|
263
|
+
* signer reads `other_signer`; one naming the target's signer under a key
|
|
264
|
+
* not bound to it reads `publisher_key_unbound`;
|
|
265
|
+
* - any-with-binding (`corroborates`, `contradicts`) needs the key bound and a
|
|
266
|
+
* `signer.bindingTier` in `BINDING_TIERS`.
|
|
267
|
+
* It also names each required §8.12.1 payload field the node lacks. Pure; it moves
|
|
268
|
+
* no status: `resolveLifecycleFromChain` alone derives the lifecycle.
|
|
119
269
|
*/
|
|
120
|
-
export declare function
|
|
270
|
+
export declare function checkAttestationNode(carried: CarriedLifecycleNode, context?: AttestationCheckContext): AttestationCheck;
|
package/dist/lifecycle.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// Lifecycle attestation checks (spec §9.2 check #10, §8.10) — browser-safe pure
|
|
2
2
|
// logic.
|
|
3
3
|
//
|
|
4
|
-
// Lifecycle state (withdrawn /
|
|
5
|
-
// signed `attestation/*` nodes referencing the content node by
|
|
6
|
-
// each verified independently. Backwards-compat (§8.10.4): when
|
|
7
|
-
// envelopes are present, the legacy `withdrawnAt` /
|
|
8
|
-
// honored instead.
|
|
4
|
+
// Lifecycle state (active / withdrawn / superseded) is derived from a chain of
|
|
5
|
+
// separately-signed `attestation/*` nodes referencing the content node by
|
|
6
|
+
// `targetNodeId`, each verified independently. Backwards-compat (§8.10.4): when
|
|
7
|
+
// no attestation envelopes are present, the legacy `withdrawnAt` /
|
|
8
|
+
// `reinstatedAt` columns are honored instead.
|
|
9
9
|
//
|
|
10
10
|
// This file holds only the PURE ordering / status-derivation / per-node
|
|
11
11
|
// verification logic (factored from the server `verify.ts`). The DB + blob fetch
|
|
@@ -15,9 +15,14 @@
|
|
|
15
15
|
// status/withdrawnAt/reason) rather than re-running the chain — verifying the
|
|
16
16
|
// signed attestation chain independently in the browser is civic-ai-tools-website#119.
|
|
17
17
|
import { computeEnvelopeHash } from "./canonicalization.js";
|
|
18
|
-
import { verifySignature } from "./signature.js";
|
|
19
|
-
import {
|
|
20
|
-
|
|
18
|
+
import { extractRawPublicKey, verifySignature } from "./signature.js";
|
|
19
|
+
import { checkSignerIdentity } from "./checks.js";
|
|
20
|
+
import { isKeyDerivedIdentifier } from "./did-key.js";
|
|
21
|
+
import { verifyKeyTrust } from "./trust-registry.js";
|
|
22
|
+
import { ATTESTATION_WITHDRAWS, ATTESTATION_REINSTATES, ATTESTATION_SUPERSEDES, ATTESTATION_REVISES, ATTESTATION_CORROBORATES, ATTESTATION_CONTRADICTS, LIFECYCLE_STATUS_ATTESTATION_TYPES, LIFECYCLE_CHAIN_ATTESTATION_TYPES, } from "./attestation.js";
|
|
23
|
+
/** Lifecycle status (spec §8.10.6). Only the attestation chain derives
|
|
24
|
+
* `superseded`; the legacy columns (§8.10.4) know no supersession. */
|
|
25
|
+
export const LIFECYCLE_STATUSES = ['active', 'withdrawn', 'superseded'];
|
|
21
26
|
/** Which representation determined the lifecycle status. `none` = never
|
|
22
27
|
* withdrawn, no attestations and no legacy columns. */
|
|
23
28
|
export const LIFECYCLE_SOURCES = [
|
|
@@ -50,20 +55,46 @@ function compareLifecycleOrder(a, b) {
|
|
|
50
55
|
}
|
|
51
56
|
/**
|
|
52
57
|
* Check #10 (chain path) — derive lifecycle status from a set of verified
|
|
53
|
-
* attestation views (spec §8.10.1, §8.10.3
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
+
* attestation views (spec §8.10.1, §8.10.3, §8.10.6; typedstandards#113 G0 D2).
|
|
59
|
+
*
|
|
60
|
+
* The chain holds the publisher-only lifecycle sub-types
|
|
61
|
+
* (`LIFECYCLE_CHAIN_ATTESTATION_TYPES`: withdraws, reinstates, supersedes,
|
|
62
|
+
* revises), ordered by envelope timestamp, ties by nodeId. The status is read
|
|
63
|
+
* from the latest signer-matched node among withdraws, reinstates and
|
|
64
|
+
* supersedes (`LIFECYCLE_STATUS_ATTESTATION_TYPES`):
|
|
65
|
+
* - `withdraws` → withdrawn;
|
|
66
|
+
* - `supersedes` → superseded;
|
|
67
|
+
* - `reinstates` → superseded when a signer-matched `supersedes` precedes it in
|
|
68
|
+
* chain order, active otherwise.
|
|
69
|
+
* `revises` is surfaced in the chain and never moves the status (§8.10.5: no
|
|
70
|
+
* deprecation signal). Non-signer-matched attestations are kept in the surfaced
|
|
71
|
+
* chain for transparency but do NOT move the status (retention asymmetry,
|
|
72
|
+
* §8.10.3). The latest signer-matched `supersedes` names the successor and the
|
|
73
|
+
* supersession time.
|
|
74
|
+
*
|
|
75
|
+
* For a chain of withdraws and reinstates only, the output is what it was before
|
|
76
|
+
* `superseded` existed.
|
|
58
77
|
*/
|
|
59
78
|
export function resolveLifecycleFromChain(views) {
|
|
60
79
|
const chain = views
|
|
61
|
-
.filter((v) =>
|
|
80
|
+
.filter((v) => LIFECYCLE_CHAIN_ATTESTATION_TYPES.includes(v.type))
|
|
62
81
|
.slice()
|
|
63
82
|
.sort(compareLifecycleOrder);
|
|
64
|
-
const signerMatched = chain.filter((v) => v.signerMatchesTarget);
|
|
83
|
+
const signerMatched = chain.filter((v) => v.signerMatchesTarget && LIFECYCLE_STATUS_ATTESTATION_TYPES.includes(v.type));
|
|
65
84
|
const latest = signerMatched[signerMatched.length - 1];
|
|
66
|
-
const
|
|
85
|
+
const latestSupersede = [...signerMatched]
|
|
86
|
+
.reverse()
|
|
87
|
+
.find((v) => v.type === ATTESTATION_SUPERSEDES);
|
|
88
|
+
let status = 'active';
|
|
89
|
+
if (latest?.type === ATTESTATION_WITHDRAWS)
|
|
90
|
+
status = 'withdrawn';
|
|
91
|
+
else if (latest?.type === ATTESTATION_SUPERSEDES)
|
|
92
|
+
status = 'superseded';
|
|
93
|
+
// A reinstatement returns the node to where it stood before the withdrawal:
|
|
94
|
+
// superseded if a counting supersedes precedes it, which it does whenever one
|
|
95
|
+
// exists, since the reinstatement is the latest counting node.
|
|
96
|
+
else if (latest?.type === ATTESTATION_REINSTATES && latestSupersede)
|
|
97
|
+
status = 'superseded';
|
|
67
98
|
const latestWithdraw = [...signerMatched]
|
|
68
99
|
.reverse()
|
|
69
100
|
.find((v) => v.type === ATTESTATION_WITHDRAWS);
|
|
@@ -86,13 +117,22 @@ export function resolveLifecycleFromChain(views) {
|
|
|
86
117
|
reinstatedReason: latestReinstate.reason,
|
|
87
118
|
}
|
|
88
119
|
: {}),
|
|
120
|
+
...(latestSupersede
|
|
121
|
+
? {
|
|
122
|
+
supersededAt: latestSupersede.createdAt,
|
|
123
|
+
...(latestSupersede.successorNodeId
|
|
124
|
+
? { successorNodeId: latestSupersede.successorNodeId }
|
|
125
|
+
: {}),
|
|
126
|
+
}
|
|
127
|
+
: {}),
|
|
89
128
|
};
|
|
90
129
|
}
|
|
91
130
|
/**
|
|
92
131
|
* Check #10 (legacy fallback) — derive lifecycle status from the pre-PR3
|
|
93
132
|
* `withdrawnAt` / `reinstatedAt` columns (spec §8.10.4). Used when a content node
|
|
94
133
|
* has no attestation envelopes. Withdrawn iff `withdrawnAt` is set and
|
|
95
|
-
* `reinstatedAt` is not.
|
|
134
|
+
* `reinstatedAt` is not. It never derives `superseded`: the columns record no
|
|
135
|
+
* supersession.
|
|
96
136
|
*/
|
|
97
137
|
export function resolveLifecycleFromLegacyColumns(columns) {
|
|
98
138
|
if (!columns.withdrawnAt) {
|
|
@@ -114,6 +154,43 @@ export function resolveLifecycleFromLegacyColumns(columns) {
|
|
|
114
154
|
function pickString(v) {
|
|
115
155
|
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
116
156
|
}
|
|
157
|
+
function sameKey(a, b) {
|
|
158
|
+
try {
|
|
159
|
+
const x = extractRawPublicKey(a);
|
|
160
|
+
const y = extractRawPublicKey(b);
|
|
161
|
+
return x.length === y.length && x.every((byte, i) => byte === y[i]);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Whether a lifecycle attestation's signing key is bound to the signer it names.
|
|
169
|
+
* - A key-derived identifier (`did:key:`) binds by derivation from `publicKey`,
|
|
170
|
+
* under the rule check #14 applies to a record: it binds when #14 reads
|
|
171
|
+
* `key_derived_match`.
|
|
172
|
+
* - Any other identifier binds when `publicKey` is the target record's own
|
|
173
|
+
* signing key, or when a registry fetched from its declared URL lists it
|
|
174
|
+
* under that identifier: check #14 reads `ok` for the node's `kid`, and
|
|
175
|
+
* check #5 reads the `(kid, publicKey)` pair as verified. The node carries
|
|
176
|
+
* no verified signing time, so a deprecated key does not bind.
|
|
177
|
+
* A registry carried in a bundle, or one with no stated provenance, never binds.
|
|
178
|
+
*/
|
|
179
|
+
function isKeyBound(node, publicKey, kid, binding) {
|
|
180
|
+
const signer = node['signer'];
|
|
181
|
+
if (!signer || typeof signer !== 'object' || typeof signer.identifier !== 'string')
|
|
182
|
+
return false;
|
|
183
|
+
if (isKeyDerivedIdentifier(signer.identifier)) {
|
|
184
|
+
return checkSignerIdentity(node, kid, binding.registry, publicKey).status === 'key_derived_match';
|
|
185
|
+
}
|
|
186
|
+
if (binding.targetPublicKey && sameKey(publicKey, binding.targetPublicKey))
|
|
187
|
+
return true;
|
|
188
|
+
if (!kid || !binding.registry || binding.registryProvenance !== 'declared-url')
|
|
189
|
+
return false;
|
|
190
|
+
if (checkSignerIdentity(node, kid, binding.registry).status !== 'ok')
|
|
191
|
+
return false;
|
|
192
|
+
return verifyKeyTrust(publicKey, kid, undefined, binding.registry).verified;
|
|
193
|
+
}
|
|
117
194
|
/**
|
|
118
195
|
* Resolve lifecycle (#10) INDEPENDENTLY from carried signed attestation nodes —
|
|
119
196
|
* the browser/offline path that reaches `source: 'attestation-chain'` with no
|
|
@@ -127,12 +204,28 @@ function pickString(v) {
|
|
|
127
204
|
* - INTEGRITY: the recomputed envelope hash MUST equal the stored `nodeId`.
|
|
128
205
|
* - SIGNATURE: the Ed25519(ph) signature MUST verify over that hash.
|
|
129
206
|
* A node failing any of these is EXCLUDED (a forged/tampered/misdirected transition
|
|
130
|
-
* cannot move the status).
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
207
|
+
* cannot move the status). A surviving node is signer-matched only when its
|
|
208
|
+
* `signer.identifier` equals the target's AND its signing key is bound to that
|
|
209
|
+
* identifier (`keyBound`), by what `binding` supplies:
|
|
210
|
+
* - a key-derived identifier (`did:key:`) binds by derivation from the node's own
|
|
211
|
+
* public key, the rule check #14 applies to a record;
|
|
212
|
+
* - any other identifier binds by `binding.targetPublicKey` (the node is signed by
|
|
213
|
+
* the target record's own key), or by `binding.registry` when
|
|
214
|
+
* `binding.registryProvenance` is `declared-url` and it lists the node's
|
|
215
|
+
* `(kid, publicKey)` under that identifier, read by the rules of checks #5 and
|
|
216
|
+
* #14. A registry carried in a bundle, or with no stated provenance, never binds.
|
|
217
|
+
* Each view records `namesTarget` (the identifiers are equal) beside `keyBound`, so a
|
|
218
|
+
* reader can tell a third party's node from one that names the publisher under a key
|
|
219
|
+
* not bound to it.
|
|
220
|
+
* A surviving node that lacks a payload field §8.12.1 requires of its sub-type
|
|
221
|
+
* (§8.12.3; `ATTESTATION_REQUIRED_FIELDS`, absent, null or the empty string) is not
|
|
222
|
+
* signer-matched either, whoever signed it: it stays in the chain, names the fields
|
|
223
|
+
* in `missingFields`, and does not move the status.
|
|
224
|
+
* Surviving nodes go to `resolveLifecycleFromChain`, which applies the §8.10.3
|
|
225
|
+
* retention asymmetry (a valid but non-signer-matched attestation is surfaced in the
|
|
226
|
+
* chain yet does NOT move the publisher's status).
|
|
134
227
|
*/
|
|
135
|
-
export function verifyLifecycleChain(carried, contentNodeId, targetSignerIdentifier) {
|
|
228
|
+
export function verifyLifecycleChain(carried, contentNodeId, targetSignerIdentifier, binding = {}) {
|
|
136
229
|
const views = [];
|
|
137
230
|
for (const entry of carried) {
|
|
138
231
|
// Reachability: the attestation must reference THIS content node.
|
|
@@ -144,20 +237,154 @@ export function verifyLifecycleChain(carried, contentNodeId, targetSignerIdentif
|
|
|
144
237
|
continue;
|
|
145
238
|
const signer = entry.node['signer'];
|
|
146
239
|
const metadata = entry.node['metadata'];
|
|
240
|
+
const keyBound = isKeyBound(entry.node, entry.signature?.publicKey ?? '', pickString(entry.signature?.kid), binding);
|
|
241
|
+
const namesTarget = !!signer && signer.identifier === targetSignerIdentifier;
|
|
242
|
+
const type = pickString(entry.node['type']) ?? '';
|
|
243
|
+
const missingFields = missingRequiredFields(entry.node, type);
|
|
147
244
|
views.push({
|
|
148
245
|
nodeId: entry.nodeId,
|
|
149
|
-
type
|
|
246
|
+
type,
|
|
150
247
|
signer,
|
|
151
248
|
createdAt: pickString(metadata?.['createdAt']) ?? '',
|
|
152
249
|
reason: pickString(entry.node['reason']),
|
|
153
250
|
effectiveAt: pickString(entry.node['effectiveAt']),
|
|
154
251
|
priorWithdrawalNodeId: pickString(entry.node['priorWithdrawalNodeId']),
|
|
252
|
+
successorNodeId: pickString(entry.node['successorNodeId']),
|
|
155
253
|
signatureValid: verdict.signatureValid,
|
|
156
254
|
nodeIdMatches: verdict.nodeIdMatches,
|
|
157
255
|
hasTimestamp: !!entry.hasTimestamp,
|
|
158
256
|
hasRekor: !!entry.hasRekor,
|
|
159
|
-
signerMatchesTarget:
|
|
257
|
+
signerMatchesTarget: namesTarget && keyBound && missingFields.length === 0,
|
|
258
|
+
keyBound,
|
|
259
|
+
namesTarget,
|
|
260
|
+
missingFields,
|
|
160
261
|
});
|
|
161
262
|
}
|
|
162
263
|
return resolveLifecycleFromChain(views);
|
|
163
264
|
}
|
|
265
|
+
/** The rule of each sub-type `checkAttestationNode` checks. `publishes`,
|
|
266
|
+
* `locatedAt` and the specific-role-required sub-types have none here. */
|
|
267
|
+
export const ATTESTATION_AUTHORIZATION_RULES = Object.freeze({
|
|
268
|
+
[ATTESTATION_WITHDRAWS]: 'publisher-only',
|
|
269
|
+
[ATTESTATION_REINSTATES]: 'publisher-only',
|
|
270
|
+
[ATTESTATION_SUPERSEDES]: 'publisher-only',
|
|
271
|
+
[ATTESTATION_REVISES]: 'publisher-only',
|
|
272
|
+
[ATTESTATION_CORROBORATES]: 'any-with-binding',
|
|
273
|
+
[ATTESTATION_CONTRADICTS]: 'any-with-binding',
|
|
274
|
+
});
|
|
275
|
+
/** Each checked sub-type's required payload fields (spec §8.12.1), beyond the
|
|
276
|
+
* structural primitive. Optional fields (`effectiveAt`, a reinstatement's
|
|
277
|
+
* `reason`, `reasoning`) are not listed. */
|
|
278
|
+
export const ATTESTATION_REQUIRED_FIELDS = Object.freeze({
|
|
279
|
+
[ATTESTATION_WITHDRAWS]: ['targetNodeId', 'reason'],
|
|
280
|
+
[ATTESTATION_REINSTATES]: ['targetNodeId', 'priorWithdrawalNodeId'],
|
|
281
|
+
[ATTESTATION_SUPERSEDES]: ['targetNodeId', 'successorNodeId'],
|
|
282
|
+
[ATTESTATION_REVISES]: ['targetNodeId', 'successorNodeId'],
|
|
283
|
+
[ATTESTATION_CORROBORATES]: ['targetNodeId', 'scope'],
|
|
284
|
+
[ATTESTATION_CONTRADICTS]: ['targetNodeId', 'scope'],
|
|
285
|
+
});
|
|
286
|
+
/** The `signer.bindingTier` values an any-with-binding node may carry: the §8.5
|
|
287
|
+
* graded identity ladder, and `platform` (spec §8.1.1). */
|
|
288
|
+
export const BINDING_TIERS = ['pseudonymous', 'oauth', 'orcid', 'did-web', 'notarized', 'platform'];
|
|
289
|
+
/**
|
|
290
|
+
* What `checkAttestationNode` reads for a node, first failure first:
|
|
291
|
+
* - `node_id_mismatch` — the recomputed envelope hash is not the stored nodeId;
|
|
292
|
+
* - `signature_invalid` — the signature does not verify over it;
|
|
293
|
+
* - `unsigned` — the node carries no signature, so nothing can be bound;
|
|
294
|
+
* - `not_checked` — publisher-only with no attested record supplied, or a
|
|
295
|
+
* sub-type this check has no rule for;
|
|
296
|
+
* - `other_signer` — publisher-only: the node names a signer other than the
|
|
297
|
+
* attested record's, a third party's event;
|
|
298
|
+
* - `publisher_key_unbound` — publisher-only: the node names the attested
|
|
299
|
+
* record's signer, but its signing key is not bound to that signer;
|
|
300
|
+
* - `key_unbound` — any-with-binding: the signing key is not bound to the signer
|
|
301
|
+
* the node names;
|
|
302
|
+
* - `binding_tier_off_ladder` — any-with-binding: `signer.bindingTier` is not in
|
|
303
|
+
* `BINDING_TIERS`;
|
|
304
|
+
* - `authorized` — the sub-type's rule holds.
|
|
305
|
+
*/
|
|
306
|
+
export const ATTESTATION_AUTHORIZATION_STATUSES = [
|
|
307
|
+
'authorized',
|
|
308
|
+
'not_checked',
|
|
309
|
+
'other_signer',
|
|
310
|
+
'publisher_key_unbound',
|
|
311
|
+
'key_unbound',
|
|
312
|
+
'binding_tier_off_ladder',
|
|
313
|
+
'unsigned',
|
|
314
|
+
'signature_invalid',
|
|
315
|
+
'node_id_mismatch',
|
|
316
|
+
];
|
|
317
|
+
function isAbsent(v) {
|
|
318
|
+
return v === undefined || v === null || v === '';
|
|
319
|
+
}
|
|
320
|
+
/** Each payload field §8.12.1 requires of `type` that `node` lacks, by name. */
|
|
321
|
+
function missingRequiredFields(node, type) {
|
|
322
|
+
return (ATTESTATION_REQUIRED_FIELDS[type] ?? []).filter((f) => isAbsent(node[f]));
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Check one `attestation/*` node on its own (spec §8.12.3), for `withdraws`,
|
|
326
|
+
* `reinstates`, `supersedes`, `revises`, `corroborates` and `contradicts`, in
|
|
327
|
+
* order:
|
|
328
|
+
* 1. integrity and signature, as `verifyAttestationNode` reads them;
|
|
329
|
+
* 2. the key binding, by the rule `verifyLifecycleChain` applies: a key-derived
|
|
330
|
+
* identifier binds by derivation; any other identifier binds by a registry
|
|
331
|
+
* fetched from its declared URL, and, for a publisher-only node only, by the
|
|
332
|
+
* attested record's own signing key;
|
|
333
|
+
* 3. the sub-type's authorization rule:
|
|
334
|
+
* - publisher-only (the four lifecycle sub-types) runs when `context.target`
|
|
335
|
+
* is supplied, and otherwise reads `not_checked`. A node naming another
|
|
336
|
+
* signer reads `other_signer`; one naming the target's signer under a key
|
|
337
|
+
* not bound to it reads `publisher_key_unbound`;
|
|
338
|
+
* - any-with-binding (`corroborates`, `contradicts`) needs the key bound and a
|
|
339
|
+
* `signer.bindingTier` in `BINDING_TIERS`.
|
|
340
|
+
* It also names each required §8.12.1 payload field the node lacks. Pure; it moves
|
|
341
|
+
* no status: `resolveLifecycleFromChain` alone derives the lifecycle.
|
|
342
|
+
*/
|
|
343
|
+
export function checkAttestationNode(carried, context = {}) {
|
|
344
|
+
const node = carried.node;
|
|
345
|
+
const type = pickString(node['type']) ?? '';
|
|
346
|
+
const rule = ATTESTATION_AUTHORIZATION_RULES[type] ?? null;
|
|
347
|
+
const verdict = verifyAttestationNode(node, carried.nodeId, carried.signature ?? null);
|
|
348
|
+
const missingFields = missingRequiredFields(node, type);
|
|
349
|
+
const publicKey = carried.signature?.publicKey ?? '';
|
|
350
|
+
const signed = verdict.nodeIdMatches && verdict.signatureValid === true;
|
|
351
|
+
const keyBound = signed &&
|
|
352
|
+
isKeyBound(node, publicKey, pickString(carried.signature?.kid), {
|
|
353
|
+
...(rule === 'publisher-only' && context.target ? { targetPublicKey: context.target.publicKey } : {}),
|
|
354
|
+
...(context.registry ? { registry: context.registry } : {}),
|
|
355
|
+
...(context.registryProvenance ? { registryProvenance: context.registryProvenance } : {}),
|
|
356
|
+
});
|
|
357
|
+
const result = (status) => ({
|
|
358
|
+
nodeId: verdict.nodeId,
|
|
359
|
+
type,
|
|
360
|
+
rule,
|
|
361
|
+
nodeIdMatches: verdict.nodeIdMatches,
|
|
362
|
+
signatureValid: verdict.signatureValid,
|
|
363
|
+
keyBound,
|
|
364
|
+
status,
|
|
365
|
+
missingFields,
|
|
366
|
+
});
|
|
367
|
+
if (!verdict.nodeIdMatches)
|
|
368
|
+
return result('node_id_mismatch');
|
|
369
|
+
if (verdict.signatureValid === false)
|
|
370
|
+
return result('signature_invalid');
|
|
371
|
+
if (verdict.signatureValid === null)
|
|
372
|
+
return result('unsigned');
|
|
373
|
+
if (rule === null)
|
|
374
|
+
return result('not_checked');
|
|
375
|
+
const signer = node['signer'];
|
|
376
|
+
const identifier = signer && typeof signer === 'object' ? signer.identifier : undefined;
|
|
377
|
+
if (rule === 'publisher-only') {
|
|
378
|
+
if (!context.target)
|
|
379
|
+
return result('not_checked');
|
|
380
|
+
if (identifier !== context.target.signerIdentifier)
|
|
381
|
+
return result('other_signer');
|
|
382
|
+
return result(keyBound ? 'authorized' : 'publisher_key_unbound');
|
|
383
|
+
}
|
|
384
|
+
if (!keyBound)
|
|
385
|
+
return result('key_unbound');
|
|
386
|
+
const tier = signer && typeof signer === 'object' ? signer.bindingTier : undefined;
|
|
387
|
+
if (!BINDING_TIERS.includes(tier))
|
|
388
|
+
return result('binding_tier_off_ladder');
|
|
389
|
+
return result('authorized');
|
|
390
|
+
}
|
package/dist/verify.d.ts
CHANGED
|
@@ -39,13 +39,20 @@ export interface EnvelopeIntegrityResult {
|
|
|
39
39
|
/** Present only when `status === 'unavailable'`. */
|
|
40
40
|
reason?: ContentUnavailableReason;
|
|
41
41
|
}
|
|
42
|
-
/** Lifecycle STATE as carried by the WS1 sidecar (`CommitmentLifecycle`).
|
|
42
|
+
/** Lifecycle STATE as carried by the WS1 sidecar (`CommitmentLifecycle`). The
|
|
43
|
+
* STATE path (`resolveLifecycleFromLegacyColumns`) reads only the withdrawal and
|
|
44
|
+
* reinstatement columns, so a sidecar's `superseded` never reaches #10 without
|
|
45
|
+
* the signed chain. */
|
|
43
46
|
export interface CommitmentLifecycleState {
|
|
44
|
-
status: 'active' | 'withdrawn';
|
|
47
|
+
status: 'active' | 'withdrawn' | 'superseded';
|
|
45
48
|
withdrawnAt?: string;
|
|
46
49
|
withdrawnReason?: string;
|
|
47
50
|
reinstatedAt?: string;
|
|
48
51
|
reinstatedReason?: string;
|
|
52
|
+
/** When the record was superseded (the latest counting `supersedes`). */
|
|
53
|
+
supersededAt?: string;
|
|
54
|
+
/** The superseding record's nodeId. */
|
|
55
|
+
successorNodeId?: string;
|
|
49
56
|
}
|
|
50
57
|
/**
|
|
51
58
|
* Everything `verifyRecord` needs, aligned to the WS1 commitment sidecar +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@typedstandards/verify-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Portable, browser-safe verification core for Typed Standards record packages — the §9.2 check suite, single-sourced so it verifies identically on a server and in the browser.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nathan Storey",
|