@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.
- package/README.md +201 -0
- 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
|
+
}
|