@intoco/verifier 0.2.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.
Files changed (2) hide show
  1. package/README.md +201 -0
  2. package/package.json +33 -0
package/README.md ADDED
@@ -0,0 +1,201 @@
1
+ # @intoco/verifier
2
+
3
+ Verify **intoco seal signatures** autonomously — no intoco server, no account required.
4
+
5
+ Works in **Node.js ≥ 22** and **modern browsers**. Pure JS — zero native compilation.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @intoco/verifier
11
+ ```
12
+
13
+ ## Quick start — with automatic key lookup
14
+
15
+ The typical usage for a third-party verifier. Fetch the JWKS once, cache it, verify offline.
16
+
17
+ ```ts
18
+ import { verifySealAgainstJwks } from '@intoco/verifier';
19
+
20
+ // Fetch the authority JWKS (cache this — keys rotate rarely)
21
+ const jwks = await fetch('https://intoco.fr/v1/keys').then(r => r.json());
22
+
23
+ // Fetch the seal you want to verify.
24
+ // `<jeton>` is an EPHEMERAL monstration token: a pole of the ato mints it when they show
25
+ // the seal (typically encoded in a QR code on their own screen), and it dies within ~90
26
+ // seconds. There is no durable URL for a seal — that is deliberate.
27
+ // For a durable, opposable artefact, use the attestation at /p/:code instead.
28
+ const { payload, signature } = await fetch('https://intoco.fr/v1/monstrations/<jeton>')
29
+ .then(r => r.json());
30
+
31
+ const result = await verifySealAgainstJwks({ seal_payload: payload, signature, jwks });
32
+
33
+ if (result.verdict === 'valid') {
34
+ if (result.key_validity === 'expired') {
35
+ console.warn('Seal was signed with a now-retired key — still cryptographically valid');
36
+ }
37
+ if (result.key_validity === 'unknown') {
38
+ console.warn('Server does not report this key\'s validity window — see "Key validity window" below');
39
+ }
40
+ console.log('Valid seal');
41
+ console.log(' ato_id :', result.ato_id);
42
+ console.log(' state :', result.sealed_state);
43
+ console.log(' sealed at :', result.sealed_at);
44
+ console.log(' sealed by :', result.sealed_by);
45
+ } else if (result.verdict === 'invalid') {
46
+ // The cryptography spoke: a real mismatch was detected.
47
+ console.error('FRAUD DETECTED — reason:', result.reason);
48
+ // 'PAYLOAD_MISMATCH' → payload was tampered after signing
49
+ // 'INVALID_SIGNATURE' → signature bytes are malformed
50
+ } else {
51
+ // 'unverifiable' — the cryptography was never reached. This package simply lacks
52
+ // what it needs to judge. NOT an accusation — see "Three verdicts" below.
53
+ console.warn('Cannot verify — reason:', result.reason);
54
+ // 'UNKNOWN_SPEC_VERSION' → spec_version is not in ACCEPTED_SPEC_VERSIONS
55
+ // 'MISSING_REQUIRED_FIELD' → a required SealPayload field is absent
56
+ // 'KEY_NOT_FOUND' → key_id in payload not present in JWKS
57
+ // 'INVALID_JWKS' → JWKS structure is invalid
58
+ }
59
+ ```
60
+
61
+ ## Low-level — supply the public key directly
62
+
63
+ Use this when you already have the matching public key (e.g. from a local archive).
64
+
65
+ ```ts
66
+ import { verifySeal } from '@intoco/verifier';
67
+
68
+ const result = await verifySeal({
69
+ seal_payload: payload,
70
+ signature: seal.signature,
71
+ authority_public_key: key.public_key, // base64url-encoded Ed25519 public key
72
+ });
73
+ ```
74
+
75
+ ## Three verdicts, never two — `valid` / `invalid` / `unverifiable`
76
+
77
+ Every result carries a `verdict`, not a boolean. The two failure verdicts are **not**
78
+ interchangeable, and this package never collapses one into the other:
79
+
80
+ - **`invalid`** — the cryptography **spoke**: a real mismatch was detected (a forged or
81
+ tampered payload, or a malformed signature). This is a finding of fraud.
82
+ - **`unverifiable`** — the cryptography was **never reached**: this package simply lacks
83
+ what it needs to judge (an unrecognized format, a missing field, a key it wasn't given).
84
+ Nothing is accused here — the seal may be perfectly genuine.
85
+
86
+ Confusing the two would mean this package could brand a seal fraudulent when all it
87
+ actually lacked was, say, the right key — a claim it never verified. `unverifiable` is
88
+ never rendered as `invalid`.
89
+
90
+ ## `ato/v1` and `ato/v2` — both accepted
91
+
92
+ This package accepts `spec_version: 'ato/v1'` **and** `'ato/v2'` — the exact list lives in
93
+ one place, [`ACCEPTED_SPEC_VERSIONS`](./src/index.ts). `ato/v2` was a draft from 2026-08-18
94
+ until it froze on 2026-09-09 (intoco reference implementation, `docs/REGISTRE-ARBITRAGES.md`,
95
+ *« Gel du sceau ato/v2 — sept arbitrages + périmètre »*) — frozen means no field is ever
96
+ added to it again without a new `spec_version` (`ato/v3`). Every seal sealed since
97
+ 2026-08-18, `ato/v1` or `ato/v2`, verifies with this package.
98
+
99
+ ### `sealed_by` — raw on `ato/v1`, pseudonymized on `ato/v2`
100
+
101
+ `sealed_by` identifies the repondant who requested the seal.
102
+
103
+ - On **`ato/v1`** seals, it is the **raw** repondant id.
104
+ - On **`ato/v2`** seals (since the freeze), it is a **pseudonym** — the same HMAC-derived
105
+ value used for `chain_snapshot`'s three roles, salted by `ato_id`. Two seals on two
106
+ different `ato`s never share a pseudonym for the same person; the three roles within one
107
+ seal remain comparable to each other.
108
+
109
+ This package never interprets the value either way — it only verifies the signature over
110
+ whatever string the payload carries. Seals already sealed before the freeze keep their raw
111
+ `sealed_by` forever (seals are append-only); only newly sealed `ato/v2` seals carry the
112
+ pseudonym.
113
+
114
+ ### `motif_public` — inside the transitions, not in the payload
115
+
116
+ `transitions_hash` (a required `SealPayload` field, see below) commits to an ordered array
117
+ of transitions. Since the freeze, a transition's public motif (`motif_public` — e.g. a
118
+ témoin relève or replacement, written only on a `soumis → engagé` transition) is included
119
+ in that commitment when present, the same optional-field regime as `reason_hash`: a
120
+ transition without a motif hashes exactly as it did before this field existed. This
121
+ package never sees the raw transitions array — it treats `transitions_hash` as an opaque
122
+ committed value and verifies only that the **signature** covers it. Whether that hash
123
+ truly reflects the underlying transitions is a server-side concern (the intoco reference
124
+ implementation recomputes and compares it — see its `SceauService.getPublicView`), not
125
+ something this package checks.
126
+
127
+ ## Key validity window — `key_validity`, three verdicts, not two
128
+
129
+ `valid_until` is **optional** on every key served by `GET /v1/keys`. Its absence is not a claim that the key is still valid — it means the server has not reported an end date. This package never collapses "we don't know" into "not expired": `key_validity` is `'valid' | 'expired' | 'unknown'`, computed purely from `valid_until`.
130
+
131
+ **As of 2026-09, the intoco reference implementation's production deployment emits `valid_until: undefined` for every key** — the server-side environment variable that would populate it, `INTOCO_AUTHORITY_KEY_VALID_UNTIL`, is not yet set on Railway (it stays optional and absent until the operator configures a key rotation deadline; see `src/config.ts` in the reference implementation). **Every seal verified against the live production JWKS today therefore reads `key_validity: 'unknown'`, never `'valid'`** — that is correct, not a bug: nobody has yet declared when the current key stops being valid.
132
+
133
+ ## Failure reasons, by verdict
134
+
135
+ | Verdict | Reason | Meaning |
136
+ |---|---|---|
137
+ | `invalid` | `PAYLOAD_MISMATCH` | Signature is well-formed but doesn't match — payload was tampered |
138
+ | `invalid` | `INVALID_SIGNATURE` | Signature or public key bytes are malformed (wrong length, bad base64url) |
139
+ | `unverifiable` | `UNKNOWN_SPEC_VERSION` | `spec_version` is not in `ACCEPTED_SPEC_VERSIONS` — this verifier cannot handle it |
140
+ | `unverifiable` | `MISSING_REQUIRED_FIELD` | A required `SealPayload` field is `null` or `undefined` |
141
+ | `unverifiable` | `KEY_NOT_FOUND` | `seal_payload.public_key_id` not found in the provided JWKS (`verifySealAgainstJwks` only) |
142
+ | `unverifiable` | `INVALID_JWKS` | `jwks.keys` is missing or not an array (`verifySealAgainstJwks` only) |
143
+
144
+ ## How verification works
145
+
146
+ 1. **`spec_version` check** — must be in `ACCEPTED_SPEC_VERSIONS` (`unverifiable` otherwise)
147
+ 2. **Required field check** — all 11 required `SealPayload` fields must be present
148
+ 3. **RFC 8785 canonicalization** — the payload is serialized deterministically (same algorithm used by the intoco authority when signing)
149
+ 4. **Ed25519 verify** — the signature is verified against the canonical bytes using `@noble/ed25519`
150
+
151
+ A seal verified as `verdict: 'valid'` guarantees:
152
+ - The payload has not been modified since the intoco authority signed it
153
+ - The signature was produced by the authority key identified by `public_key_id`
154
+
155
+ It does **not** guarantee that `transitions_hash` or `content_hash` correctly reflect some
156
+ external document or transition history — this package verifies the *signature*, not a
157
+ confrontation against source material it was never given.
158
+
159
+ > **Note on `key_validity`**: intoco rotates signing keys periodically. Seals issued before a key's retirement remain valid — `key_validity` lets you surface a warning (`'expired'`) or an honest "don't know" (`'unknown'`) without ever treating historical seals as invalid.
160
+
161
+ ## Types
162
+
163
+ ```ts
164
+ type SealPayload = {
165
+ spec_version: 'ato/v1' | 'ato/v2';
166
+ seal_id: string;
167
+ ato_id: string;
168
+ sealed_state: 'adressé' | 'engagé' | 'soumis' | 'clôturé';
169
+ sealed_at: string; // UTC ISO 8601, millisecond precision
170
+ sealed_by: string; // raw on ato/v1, pseudonym (64-hex) on ato/v2 — see above
171
+ chain_snapshot: { appelant_id: string; porteur_id: string; temoin_id: string };
172
+ transitions_hash: string; // sha256:<hex>
173
+ content_hash: string; // sha256:<hex>
174
+ espace_id: string;
175
+ public_key_id: string;
176
+ attachments_hash?: string[];
177
+ temoin_attesta?: boolean; // present only once a soumis→clôturé transition exists
178
+ pole_reassigned?: boolean; // present only once a pole relais/replacement has occurred
179
+ };
180
+
181
+ type VerifyResult =
182
+ | { verdict: 'valid'; key_id: string; sealed_at: string; sealed_state: string; ato_id: string; sealed_by: string }
183
+ | { verdict: 'invalid'; reason: 'INVALID_SIGNATURE' | 'PAYLOAD_MISMATCH' }
184
+ | { verdict: 'unverifiable'; reason: 'UNKNOWN_SPEC_VERSION' | 'MISSING_REQUIRED_FIELD' };
185
+
186
+ type VerifyAgainstJwksResult =
187
+ | { verdict: 'valid'; key_validity: 'valid' | 'expired' | 'unknown'; key_id: string; sealed_at: string; sealed_state: string; ato_id: string; sealed_by: string }
188
+ | { verdict: 'invalid'; reason: 'INVALID_SIGNATURE' | 'PAYLOAD_MISMATCH' }
189
+ | { verdict: 'unverifiable'; reason: 'UNKNOWN_SPEC_VERSION' | 'MISSING_REQUIRED_FIELD' | 'KEY_NOT_FOUND' | 'INVALID_JWKS' };
190
+ ```
191
+
192
+ ## Dependencies
193
+
194
+ | Package | Purpose |
195
+ |---|---|
196
+ | [`@noble/ed25519`](https://github.com/paulmillr/noble-ed25519) | Pure-JS Ed25519 — no native code, works in browsers |
197
+ | [`canonicalize`](https://github.com/nicktindall/canonicalize) | RFC 8785 JSON canonicalization |
198
+
199
+ ## License
200
+
201
+ MIT
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@intoco/verifier",
3
+ "version": "0.2.0",
4
+ "description": "Verify intoco seal signatures autonomously — no intoco server required",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": ["dist"],
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.build.json",
17
+ "test": "vitest run",
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@noble/ed25519": "3.1.0",
22
+ "canonicalize": "1.0.8"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^5.5.0",
26
+ "vitest": "^1.6.0"
27
+ },
28
+ "engines": {
29
+ "node": ">=22"
30
+ },
31
+ "license": "MIT",
32
+ "keywords": ["intoco", "seal", "ed25519", "verifier", "crypto"]
33
+ }