@typedstandards/verify-core 0.12.0 → 0.14.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 CHANGED
@@ -6,6 +6,111 @@ 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.14.0 — 2026-10-16
10
+
11
+ The W5 release (civic-ai-tools#260, shipped in #146 and #147). **A minor bump**: new exports
12
+ (`CONTENT_LAYOUT`; `parseStrictJson` and `RepeatedMemberNameError`), and a type URI check #12 newly
13
+ reads `ok`.
14
+
15
+ - **`content/layout/v1` is registered (hub ADR-0034; spec §8.14).** New export `CONTENT_LAYOUT`
16
+ (`'content/layout/v1'`). `KNOWN_TYPE_URIS` holds eighteen entries: `content/analysis/v1`, then
17
+ `content/layout/v1`, then the sixteen attestation sub-types in the §8.12.1 table order, unchanged.
18
+ Check #12 reads `ok` for a layout node, where it read `unknown_type`. No payload check is added: a
19
+ layout node's envelope verifies as any node's (§8.14.5).
20
+ - **`parseStrictJson` and `RepeatedMemberNameError` (new exports; typedstandards#145,
21
+ specification §8.2 and §9.2 check #1).** `parseStrictJson(text: string | Uint8Array)`
22
+ refuses a JSON text that repeats a member name within an object, which is not I-JSON
23
+ (RFC 7493), the input RFC 8785 JCS requires. It throws `RepeatedMemberNameError`
24
+ (`name` `'RepeatedMemberNameError'`), carrying `memberName` (unescaped) and
25
+ `pointer`, the JSON Pointer (RFC 6901) of the object that holds it. Names compare
26
+ after unescaping; the same name in two different objects is not a repeat. A text with
27
+ no repeated name returns exactly what `JSON.parse` returns, and a text that is not
28
+ JSON throws what `JSON.parse` throws, unchanged. Bytes are decoded as UTF-8 with
29
+ `fatal`. The walk keeps its own stack, so nesting the platform parser accepts does
30
+ not overflow it. No dependency is added.
31
+ - **`verifyRecord` is unchanged:** it takes a parsed package, so the refusal belongs to
32
+ whoever reads the text. `JSON.parse` keeps the last occurrence of a repeated name, and
33
+ the envelope hash is computed over the parsed object, so a caller that parses a
34
+ package with it accepts a text the specification refuses.
35
+ - **A negative vector**, `src/__fixtures__/repeated-member-name.bundle.json` (not
36
+ published): the site's minted interop bundle with a second, earlier `summary` member in
37
+ its package, which under `JSON.parse` gives the signed package's envelope and content
38
+ hashes. Its provenance is in `src/__fixtures__/README.md`.
39
+
40
+ ## 0.13.0 — 2026-09-27
41
+
42
+ The core-minors release (typedstandards#113, shipped in #114, #115 and #118). **A minor bump** —
43
+ `LIFECYCLE_STATUSES` gains `superseded`, so a consumer with an exhaustive `switch` or `Record` over
44
+ `LifecycleStatus` needs the new case, and `verifyLifecycleChain` now moves status only on a node
45
+ whose key is bound to the signer it names and that carries its required fields.
46
+
47
+ - **A lifecycle attestation moves status only when its signing key is bound to the signer it names
48
+ (typedstandards#113).** `verifyLifecycleChain` takes an optional fourth argument, a
49
+ `LifecycleKeyBinding` (`targetPublicKey`, `registry`, `registryProvenance`), and a carried node
50
+ signer-matches only when its `signer.identifier` equals the target's and its key is bound:
51
+ - a key-derived identifier (`did:key:`) binds by derivation from the node's own public key, the rule
52
+ check #14 applies to a record;
53
+ - any other identifier binds when the node is signed by the target record's own key
54
+ (`targetPublicKey`), or when a registry fetched from its declared URL (`registryProvenance:
55
+ 'declared-url'`) lists the node's `(kid, publicKey)` under that identifier, read by the rules of
56
+ checks #5 and #14. A registry carried in a bundle, or one with no stated provenance, never binds.
57
+
58
+ A node that is not bound stays in `chain` and does not move status. A caller that passes no binding
59
+ material gets a status moved only by `did:key` signers. `LifecycleAttestationView` gains an optional
60
+ `keyBound`, set by `verifyLifecycleChain`, and `CarriedLifecycleNode.signature` gains an optional
61
+ `kid`. `resolveLifecycleFromChain` is unchanged.
62
+ - **A carried lifecycle node that lacks a required payload field moves no status (typedstandards#113,
63
+ spec §8.12.3).** In `verifyLifecycleChain`, a node that lacks a field `ATTESTATION_REQUIRED_FIELDS`
64
+ lists for its sub-type (absent, null or the empty string: a `withdraws` with no `reason`, a
65
+ `reinstates` with no `priorWithdrawalNodeId`, a `supersedes` or `revises` with no
66
+ `successorNodeId`) stays in `chain` with `signerMatchesTarget` false, whoever signed it, and does not
67
+ move the status. `LifecycleAttestationView` gains an optional `missingFields`, set by
68
+ `verifyLifecycleChain` on every view: the fields the node lacks, by name, empty when it lacks none.
69
+ `resolveLifecycleFromChain` and `checkAttestationNode` are unchanged.
70
+ - **On typedstandards.org** (the site's verifier; not part of this package's API): the carried lifecycle
71
+ chain is resolved with the record's own signing key, and with the trust registry only when it was
72
+ fetched from the record's declared URL. A carried lifecycle event that names the record's signer and
73
+ lacks a required payload field adds one #10 line at `attention`, whose detail names the field, and
74
+ the #10 row lists the event under the fields it lacks. A third party's event that lacks one reads as
75
+ any third-party event does and adds no line (the owner's correction at the P5-fix gate, per G0 D6 as
76
+ corrected).
77
+ - **`superseded` is a lifecycle status (typedstandards#113, G0 D2).** `LIFECYCLE_STATUSES` is
78
+ `active`, `withdrawn`, `superseded`. `resolveLifecycleFromChain` reads the status from the latest
79
+ signer-matched node among `withdraws`, `reinstates` and `supersedes`: a `supersedes` reads
80
+ `superseded`; a `reinstates` reads `superseded` when a signer-matched `supersedes` precedes it and
81
+ `active` otherwise; a later `withdraws` reads `withdrawn`. `revises` is surfaced in `chain` and never
82
+ moves the status. `LifecycleResolution` gains `supersededAt` and `successorNodeId`, from the latest
83
+ counting `supersedes`. For a chain of `withdraws` and `reinstates` only, the output is unchanged.
84
+ `resolveLifecycleFromLegacyColumns` never derives `superseded`.
85
+ - `LifecycleAttestationView` gains optional `successorNodeId` and `namesTarget` (set by
86
+ `verifyLifecycleChain`: the node's `signer.identifier` equals the target's, bound or not).
87
+ - `CommitmentLifecycleState.status` widens to `'superseded'`, with optional `supersededAt` and
88
+ `successorNodeId`. `verifyRecord`'s STATE path still reads only the withdrawal columns.
89
+ - New constants `ATTESTATION_SUPERSEDES`, `ATTESTATION_REVISES`, `ATTESTATION_CORROBORATES`,
90
+ `ATTESTATION_CONTRADICTS`, and the lists `LIFECYCLE_STATUS_ATTESTATION_TYPES` (withdraws,
91
+ reinstates, supersedes) and `LIFECYCLE_CHAIN_ATTESTATION_TYPES` (those and revises).
92
+ `LifecycleAttestationType` and `LIFECYCLE_ATTESTATION_TYPES` stay `withdraws` and `reinstates`,
93
+ the withdrawal/reinstatement pair. produce-core also emits `supersedes` and `revises`, so they are
94
+ not the set it emits, and no verify-core check reads them.
95
+ - **A per-node authorization check, `checkAttestationNode` (typedstandards#113, G0 D6 and D4).** For
96
+ `withdraws`, `reinstates`, `supersedes`, `revises`, `corroborates` and `contradicts`, in order:
97
+ integrity and signature; the key binding, by `verifyLifecycleChain`'s rule; then the sub-type's
98
+ authorization rule (spec §8.12.3). Publisher-only (the four lifecycle sub-types) runs when the
99
+ attested record's signer and signing key are supplied, and otherwise reads `not_checked`; a node
100
+ naming another signer reads `other_signer`, and one naming the record's signer under a key not bound
101
+ to it reads `publisher_key_unbound`. Any-with-binding (`corroborates`, `contradicts`) needs the key
102
+ bound to the node's own identifier (a `did:key` by derivation; any other identifier only by a
103
+ registry fetched from its declared URL) and a `signer.bindingTier` in `BINDING_TIERS`
104
+ (`pseudonymous`, `oauth`, `orcid`, `did-web`, `notarized`, `platform`). The result names each
105
+ required §8.12.1 payload field the node lacks, in `missingFields`; it moves no status. New exports:
106
+ `checkAttestationNode`, `ATTESTATION_AUTHORIZATION_RULES`, `ATTESTATION_REQUIRED_FIELDS`,
107
+ `ATTESTATION_AUTHORIZATION_STATUSES`, `BINDING_TIERS`, and the types
108
+ `AttestationAuthorizationRule`, `AttestationAuthorizationStatus`, `AttestationCheckContext`,
109
+ `AttestationCheck`.
110
+ - **On typedstandards.org:** `superseded` reads `normal` ("Superseded by the publisher") and the #10
111
+ row names the successor. A carried lifecycle event that names the record's signer, under a key not
112
+ bound to it, adds one #10 line at `attention`; a third party's event adds nothing.
113
+
9
114
  ## 0.12.0 — 2026-09-24
10
115
 
11
116
  The certificate-chain split (typedstandards#100, shipped in #106). **A minor bump** — `ChainFailReason`
package/README.md CHANGED
@@ -64,6 +64,14 @@ const result = await verifyRecord(input, {
64
64
  // contentHash, typeResolution, signerIdentity, lifecycle, ...
65
65
  ```
66
66
 
67
+ > **Parse the package's text with `parseStrictJson`.** A record package's JSON text is
68
+ > I-JSON (RFC 7493), so a verifier refuses a text that repeats a member name within an
69
+ > object, before any hash is computed (spec §8.2, §9.2 check #1). `JSON.parse` keeps the
70
+ > last occurrence and drops the others, and `verifyRecord` takes the parsed object, so it
71
+ > cannot see a repeat. `parseStrictJson(text)` (a string, or bytes decoded as UTF-8)
72
+ > throws `RepeatedMemberNameError`, with `memberName` and `pointer` (the JSON Pointer of
73
+ > the object), on a repeated name, and otherwise returns what `JSON.parse` returns.
74
+
67
75
  > **`verifyEvidence` is the same function.** The 2026-08-19 vocabulary settlement
68
76
  > renamed the entry point to `verifyRecord` (spec Appendix J) — "evidence" is
69
77
  > retired from the artifact/infrastructure brand role and kept only for the
@@ -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 lifecycle sub-type URIs the verify side resolves (spec §8.12.1). */
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[];
@@ -1,12 +1,31 @@
1
- // Lifecycle attestation sub-type URIs (spec §8.10, §8.12.1) — browser-safe
2
- // constants. The server `attestation.ts` builder (which mints nodes and so needs
3
- // Node's `crypto.randomUUID`) re-exports these from verify-core, keeping a single
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
- /** The lifecycle sub-type URIs the verify side resolves (spec §8.12.1). */
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/checks.d.ts CHANGED
@@ -102,6 +102,7 @@ export interface ContentProfileCheck {
102
102
  * integrity failure: both labels are signature-covered.
103
103
  */
104
104
  export declare function checkContentProfile(pkg: Record<string, unknown>): ContentProfileCheck;
105
+ export declare const CONTENT_LAYOUT = "content/layout/v1";
105
106
  export declare const KNOWN_TYPE_URIS: readonly string[];
106
107
  export declare const TYPE_RESOLUTION_STATUSES: readonly ["ok", "implicit", "unknown_type"];
107
108
  export type TypeResolutionStatus = (typeof TYPE_RESOLUTION_STATUSES)[number];
package/dist/checks.js CHANGED
@@ -244,15 +244,22 @@ export function checkContentProfile(pkg) {
244
244
  return { status: 'ok', ...base };
245
245
  }
246
246
  // --- Typed-standards envelope checks (spec §9.2 checks #12, #14, #15) ---
247
- // v0.1 ratified node type URIs (spec §8.12.1). Recognized so check #12 resolves
248
- // them; only `content/analysis/v1` (+ the lifecycle sub-types) are operationalized
249
- // today, but the full ratified set is registered so conformant packages don't
250
- // render as `unknown_type`. The sixteen attestation sub-types are in the order
251
- // of the §8.12.1 table; `type-resolution.test.ts` holds that table's copy.
252
- // Exported and frozen so a check outside this package can compare the registered
253
- // set with the specification's list without being able to change it.
247
+ // The `content/layout/v1` sub-type (spec §8.14, hub ADR-0034): a standalone
248
+ // assertion of how a host's page arranges a node's parts. Registered for check
249
+ // #12; verify-core runs no payload check on it (§8.14.5).
250
+ export const CONTENT_LAYOUT = 'content/layout/v1';
251
+ // v0.1 ratified node type URIs. Recognized so check #12 resolves them; only
252
+ // `content/analysis/v1` (+ the lifecycle sub-types) are operationalized today,
253
+ // but the full ratified set is registered so conformant packages don't render
254
+ // as `unknown_type`. The two `content/*` sub-types come first:
255
+ // `content/analysis/v1`, then `content/layout/v1` (§8.14). The sixteen
256
+ // attestation sub-types follow in the order of the §8.12.1 table;
257
+ // `type-resolution.test.ts` holds that table's copy. Exported and frozen so a
258
+ // check outside this package can compare the registered set with the
259
+ // specification's list without being able to change it.
254
260
  export const KNOWN_TYPE_URIS = Object.freeze([
255
261
  'content/analysis/v1',
262
+ CONTENT_LAYOUT,
256
263
  'attestation/withdraws/v1',
257
264
  'attestation/reinstates/v1',
258
265
  'attestation/supersedes/v1',
package/dist/index.d.ts CHANGED
@@ -15,3 +15,4 @@ export * from './asn1.ts';
15
15
  export * from './x509.ts';
16
16
  export * from './rfc3161.ts';
17
17
  export * from './verify.ts';
18
+ export * from './strict-json.ts';
package/dist/index.js CHANGED
@@ -29,3 +29,4 @@ export * from "./asn1.js";
29
29
  export * from "./x509.js";
30
30
  export * from "./rfc3161.js";
31
31
  export * from "./verify.js";
32
+ export * from "./strict-json.js";
@@ -1,5 +1,8 @@
1
1
  import type { SignerIdentity } from './types.ts';
2
- export declare const LIFECYCLE_STATUSES: readonly ["active", "withdrawn"];
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
- /** Publisher-only conformance (§8.12.3): the attestation's signer.identifier
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). The current status is the latest
66
- * signer-matched lifecycle attestation by envelope timestamp: `withdraws` →
67
- * withdrawn, `reinstates` → active. Non-signer-matched attestations are kept in
68
- * the surfaced chain for transparency but do NOT move the status (retention
69
- * asymmetry, §8.10.3).
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). Surviving nodes go to `resolveLifecycleFromChain`, which
116
- * applies the §8.10.3 retention asymmetry (a valid but non-signer-matched attestation
117
- * is surfaced in the chain yet does NOT move the publisher's status). That shared
118
- * resolver is unchanged, so the server route's output stays byte-identical.
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 verifyLifecycleChain(carried: CarriedLifecycleNode[], contentNodeId: string, targetSignerIdentifier: string): LifecycleResolution;
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 / active) is derived from a chain of separately-
5
- // signed `attestation/*` nodes referencing the content node by `targetNodeId`,
6
- // each verified independently. Backwards-compat (§8.10.4): when no attestation
7
- // envelopes are present, the legacy `withdrawnAt` / `reinstatedAt` columns are
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 { ATTESTATION_WITHDRAWS, ATTESTATION_REINSTATES, LIFECYCLE_ATTESTATION_TYPES, } from "./attestation.js";
20
- export const LIFECYCLE_STATUSES = ['active', 'withdrawn'];
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). The current status is the latest
54
- * signer-matched lifecycle attestation by envelope timestamp: `withdraws` →
55
- * withdrawn, `reinstates` → active. Non-signer-matched attestations are kept in
56
- * the surfaced chain for transparency but do NOT move the status (retention
57
- * asymmetry, §8.10.3).
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) => LIFECYCLE_ATTESTATION_TYPES.includes(v.type))
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 status = latest && latest.type === ATTESTATION_WITHDRAWS ? 'withdrawn' : 'active';
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). Surviving nodes go to `resolveLifecycleFromChain`, which
131
- * applies the §8.10.3 retention asymmetry (a valid but non-signer-matched attestation
132
- * is surfaced in the chain yet does NOT move the publisher's status). That shared
133
- * resolver is unchanged, so the server route's output stays byte-identical.
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: pickString(entry.node['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: !!signer && signer.identifier === targetSignerIdentifier,
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
+ }
@@ -0,0 +1,24 @@
1
+ /** A JSON text repeats a member name within one object, so it is not I-JSON (RFC 7493). */
2
+ export declare class RepeatedMemberNameError extends Error {
3
+ /** The repeated member name, unescaped. */
4
+ readonly memberName: string;
5
+ /** The JSON Pointer (RFC 6901) of the object that holds it: `""` for the top-level value. */
6
+ readonly pointer: string;
7
+ constructor(memberName: string, pointer: string);
8
+ }
9
+ /**
10
+ * Parse a record's JSON text strictly: refuse a text that repeats a member name
11
+ * within an object (I-JSON, RFC 7493; specification §8.2), before any hash is
12
+ * computed over what it holds.
13
+ *
14
+ * - Bytes are decoded as UTF-8 with `fatal`, so malformed UTF-8 throws the
15
+ * decoder's `TypeError` rather than being replaced. A leading byte order mark is
16
+ * removed, as the decoder does by default.
17
+ * - A text that is not JSON throws what `JSON.parse` throws, unchanged.
18
+ * - A text that repeats a member name within one object throws
19
+ * `RepeatedMemberNameError`, naming the member and the JSON Pointer of the
20
+ * object. Names compare after unescaping, so `"a"` repeats `"a"`; the same
21
+ * name in two different objects is not a repeat.
22
+ * - Otherwise it returns `JSON.parse`'s value.
23
+ */
24
+ export declare function parseStrictJson(text: string | Uint8Array): unknown;
@@ -0,0 +1,109 @@
1
+ // The strict parse of a record's JSON text (typedstandards#145; specification §8.2,
2
+ // §9.2 check #1; hub ADR-0035).
3
+ //
4
+ // A record package's JSON text is I-JSON (RFC 7493), the input RFC 8785 JCS
5
+ // requires, and I-JSON allows no member name to repeat within an object. The
6
+ // platform's `JSON.parse` keeps the last occurrence of a repeated name and drops
7
+ // the earlier ones without a word, and the envelope hash is computed over the
8
+ // parsed object, so a text with a repeated name hashes as the text without it while
9
+ // a reader that keeps another occurrence sees other content. `parseStrictJson`
10
+ // refuses such a text, and otherwise returns exactly what `JSON.parse` returns.
11
+ //
12
+ // Pure: no I/O, no dependency, browser-safe. The walk keeps its own stack, so any
13
+ // nesting the platform parser accepts is walked without recursion.
14
+ /** A JSON text repeats a member name within one object, so it is not I-JSON (RFC 7493). */
15
+ export class RepeatedMemberNameError extends Error {
16
+ /** The repeated member name, unescaped. */
17
+ memberName;
18
+ /** The JSON Pointer (RFC 6901) of the object that holds it: `""` for the top-level value. */
19
+ pointer;
20
+ constructor(memberName, pointer) {
21
+ super(`JSON text is not I-JSON (RFC 7493): the member name ${JSON.stringify(memberName)} is repeated in the object at JSON Pointer ${JSON.stringify(pointer)}`);
22
+ this.name = 'RepeatedMemberNameError';
23
+ this.memberName = memberName;
24
+ this.pointer = pointer;
25
+ }
26
+ }
27
+ const QUOTE = 0x22;
28
+ const BACKSLASH = 0x5c;
29
+ const OPEN_OBJECT = 0x7b;
30
+ const CLOSE_OBJECT = 0x7d;
31
+ const OPEN_ARRAY = 0x5b;
32
+ const CLOSE_ARRAY = 0x5d;
33
+ const COMMA = 0x2c;
34
+ const escapeSegment = (s) => s.replace(/~/g, '~0').replace(/\//g, '~1');
35
+ /** The index of the quote that closes the string opening at `start`, in text known to be JSON. */
36
+ function stringEnd(text, start) {
37
+ let i = start + 1;
38
+ for (;;) {
39
+ const c = text.charCodeAt(i);
40
+ if (c === BACKSLASH)
41
+ i += 2;
42
+ else if (c === QUOTE)
43
+ return i;
44
+ else
45
+ i += 1;
46
+ }
47
+ }
48
+ /** Throw `RepeatedMemberNameError` at the first repeated name, in text `JSON.parse` has accepted. */
49
+ function refuseRepeatedNames(text) {
50
+ const stack = [];
51
+ const segments = [];
52
+ const n = text.length;
53
+ for (let i = 0; i < n; i++) {
54
+ const c = text.charCodeAt(i);
55
+ if (c === QUOTE) {
56
+ const end = stringEnd(text, i);
57
+ const top = stack[stack.length - 1];
58
+ if (top?.names && top.expectName) {
59
+ const raw = text.slice(i + 1, end);
60
+ const name = raw.includes('\\') ? JSON.parse(text.slice(i, end + 1)) : raw;
61
+ if (top.names.has(name))
62
+ throw new RepeatedMemberNameError(name, segments.map((s) => `/${escapeSegment(s)}`).join(''));
63
+ top.names.add(name);
64
+ top.segment = name;
65
+ top.expectName = false;
66
+ }
67
+ i = end;
68
+ }
69
+ else if (c === OPEN_OBJECT || c === OPEN_ARRAY) {
70
+ const parent = stack[stack.length - 1];
71
+ if (parent)
72
+ segments.push(parent.names ? parent.segment : String(parent.index));
73
+ stack.push({ names: c === OPEN_OBJECT ? new Set() : null, expectName: c === OPEN_OBJECT, segment: '', index: 0 });
74
+ }
75
+ else if (c === CLOSE_OBJECT || c === CLOSE_ARRAY) {
76
+ stack.pop();
77
+ if (stack.length > 0)
78
+ segments.pop();
79
+ }
80
+ else if (c === COMMA) {
81
+ const top = stack[stack.length - 1];
82
+ if (top.names)
83
+ top.expectName = true;
84
+ else
85
+ top.index += 1;
86
+ }
87
+ }
88
+ }
89
+ /**
90
+ * Parse a record's JSON text strictly: refuse a text that repeats a member name
91
+ * within an object (I-JSON, RFC 7493; specification §8.2), before any hash is
92
+ * computed over what it holds.
93
+ *
94
+ * - Bytes are decoded as UTF-8 with `fatal`, so malformed UTF-8 throws the
95
+ * decoder's `TypeError` rather than being replaced. A leading byte order mark is
96
+ * removed, as the decoder does by default.
97
+ * - A text that is not JSON throws what `JSON.parse` throws, unchanged.
98
+ * - A text that repeats a member name within one object throws
99
+ * `RepeatedMemberNameError`, naming the member and the JSON Pointer of the
100
+ * object. Names compare after unescaping, so `"a"` repeats `"a"`; the same
101
+ * name in two different objects is not a repeat.
102
+ * - Otherwise it returns `JSON.parse`'s value.
103
+ */
104
+ export function parseStrictJson(text) {
105
+ const source = typeof text === 'string' ? text : new TextDecoder('utf-8', { fatal: true }).decode(text);
106
+ const value = JSON.parse(source);
107
+ refuseRepeatedNames(source);
108
+ return value;
109
+ }
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.12.0",
3
+ "version": "0.14.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",