fieldlog 0.15.0 → 0.15.1

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/src/auth.ts CHANGED
@@ -1,297 +1,297 @@
1
- // auth.ts — ed25519 device keys, revocable scopes, countersign threshold.
2
- // No PKI: devices are raw public keys; an authority key signs scope grants.
3
- import { generateKeyPairSync, randomUUID, sign, verify } from 'node:crypto';
4
- import { canonicalOf, hashFor, type LogEvent } from './log.js';
5
- /**
6
- * Device identity.
7
- *
8
- * `deviceId` is the lowercase hex encoding of the ed25519 public key's
9
- * SPKI DER bytes (`publicKey.export({ type: 'spki', format: 'der' })`
10
- * rendered as hex). Registry maps, scope grants, capability tokens, and
11
- * countersignatures key on this exact string — never the PEM. An explicit
12
- * `deviceId` override (e.g. a named authority such as 'hq' in tests)
13
- * bypasses the derivation and is NOT SPKI-DER hex; production devices
14
- * always use the derived form.
15
- */
16
- export interface DeviceKeypair {
17
- deviceId: string; // lowercase hex of the ed25519 public key (SPKI DER)
18
- publicKeyPem: string;
19
- privateKeyPem: string;
20
- }
21
-
22
- export function generateDeviceKey(deviceId?: string): DeviceKeypair {
23
- const { publicKey, privateKey } = generateKeyPairSync('ed25519');
24
- const pubDer = publicKey.export({ type: 'spki', format: 'der' });
25
- const pubPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
26
- const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
27
- return {
28
- deviceId: deviceId ?? (pubDer as Buffer).toString('hex'),
29
- publicKeyPem: pubPem,
30
- privateKeyPem: privPem,
31
- };
32
- }
33
-
34
- export function signBytes(privateKeyPem: string, data: Uint8Array | string): string {
35
- const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data);
36
- return sign(null, bytes, privateKeyPem).toString('hex');
37
- }
38
-
39
- export function verifyBytes(publicKeyPem: string, data: Uint8Array | string, signatureHex: string): boolean {
40
- try {
41
- const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data);
42
- return verify(null, bytes, publicKeyPem, Buffer.from(signatureHex, 'hex'));
43
- } catch {
44
- return false;
45
- }
46
- }
47
-
48
- /** Sign the event's hash-chain hash: signature covers the whole causal history. */
49
- export function signEvent(privateKeyPem: string, ev: LogEvent): string {
50
- return signBytes(privateKeyPem, ev.hash);
51
- }
52
-
53
- export function verifyEvent(publicKeyPem: string, ev: LogEvent, signatureHex: string): boolean {
54
- const { hash } = ev;
55
- // Recompute the chain hash so a signature can't be transplanted onto edited bytes.
56
- // The auth envelope itself is never hashed (signature covers the hash).
57
- const { hash: _drop, signature: _s, countersignatures: _c, ...core } = ev;
58
- void _drop;
59
- void _s;
60
- void _c;
61
- if (hashFor(core) !== hash) return false;
62
- return verifyBytes(publicKeyPem, hash, signatureHex);
63
- }
64
-
65
- // Named TTLs: single source for grant/capability lifetimes. The relay path
66
- // uses the short capability default; callers needing longer sessions pass
67
- // an explicit ttlMs rather than forking a second magic number.
68
- export const GRANT_TTL_MS = 24 * 3600 * 1000;
69
- export const CAP_TOKEN_TTL_MS = 15 * 60 * 1000;
70
-
71
- // Scopes: revocable capability grants signed by an authority key.
72
- export interface ScopeGrant {
73
- id: string;
74
- deviceId: string;
75
- scopes: string[]; // e.g. ['entries:append', 'entries:resolve']
76
- issuedBy: string; // authority deviceId / name
77
- issuedAt: number;
78
- expiresAt: number;
79
- signature?: string; // authority signature over the canonical grant
80
- }
81
-
82
- export function canonicalGrant(g: Omit<ScopeGrant, 'signature'>): string {
83
- return JSON.stringify({
84
- id: g.id,
85
- deviceId: g.deviceId,
86
- scopes: [...g.scopes].sort(),
87
- issuedBy: g.issuedBy,
88
- issuedAt: g.issuedAt,
89
- expiresAt: g.expiresAt,
90
- });
91
- }
92
-
93
- export function issueGrant(
94
- authorityPrivatePem: string,
95
- issuedBy: string,
96
- deviceId: string,
97
- scopes: string[],
98
- ttlMs = GRANT_TTL_MS,
99
- now = Date.now(),
100
- ): ScopeGrant {
101
- const grant: Omit<ScopeGrant, 'signature'> = {
102
- id: randomUUID(),
103
- deviceId,
104
- scopes,
105
- issuedBy,
106
- issuedAt: now,
107
- expiresAt: now + ttlMs,
108
- };
109
- return { ...grant, signature: signBytes(authorityPrivatePem, canonicalGrant(grant)) };
110
- }
111
-
112
- export class RevocationList {
113
- private revoked = new Set<string>(); // grant ids; dynamic membership → Set
114
- revoke(grantId: string): void {
115
- this.revoked.add(grantId);
116
- }
117
- isRevoked(grantId: string): boolean {
118
- return this.revoked.has(grantId);
119
- }
120
- get size(): number {
121
- return this.revoked.size;
122
- }
123
- }
124
-
125
- export function verifyGrant(
126
- authorityPublicPem: string,
127
- grant: ScopeGrant,
128
- scope: string,
129
- revocations?: RevocationList,
130
- now = Date.now(),
131
- ): boolean {
132
- if (!grant.signature) return false;
133
- if (grant.expiresAt <= grant.issuedAt) return false; // malformed lifetime: fail closed
134
- if (now < grant.issuedAt) return false; // not-before: usable only from issuance
135
- if (now > grant.expiresAt) return false;
136
- if (revocations?.isRevoked(grant.id)) return false;
137
- const { signature, ...core } = grant;
138
- if (!verifyBytes(authorityPublicPem, canonicalGrant(core), signature)) return false;
139
- return grant.scopes.includes(scope);
140
- }
141
-
142
- // Capability tokens: the device key itself signs a scope+expiry token.
143
- // The relay holds a deviceId -> publicKey registry and verifies the
144
- // signature + scope + expiry on every push/pull. No authority key involved.
145
- // Self-signed limit: possession of a valid token equals the device key for
146
- // its scopes until expiry or revocation — see docs/capability-token.md.
147
-
148
- export interface CapToken {
149
- id: string; // per-token id: revocation is granular, never whole-device only
150
- deviceId: string;
151
- scopes: string[]; // e.g. ['relay:push', 'relay:pull']
152
- issuedAt: number;
153
- expiresAt: number;
154
- notBefore?: number; // optional activation floor: now < notBefore → not yet valid
155
- signature?: string; // device signature over the canonical token
156
- }
157
- export function canonicalCapToken(t: Omit<CapToken, 'signature'>): string {
158
- return JSON.stringify({
159
- id: t.id,
160
- deviceId: t.deviceId,
161
- scopes: [...t.scopes].sort(),
162
- issuedAt: t.issuedAt,
163
- expiresAt: t.expiresAt,
164
- ...(t.notBefore !== undefined ? { notBefore: t.notBefore } : {}),
165
- });
166
- }
167
-
168
- export function mintCapToken(
169
- privateKeyPem: string,
170
- deviceId: string,
171
- scopes: string[],
172
- ttlMs = CAP_TOKEN_TTL_MS,
173
- now = Date.now(),
174
- ): CapToken {
175
- const core: Omit<CapToken, 'signature'> = {
176
- id: randomUUID(),
177
- deviceId,
178
- scopes,
179
- issuedAt: now,
180
- expiresAt: now + ttlMs,
181
- };
182
- return { ...core, signature: signBytes(privateKeyPem, canonicalCapToken(core)) };
183
- }
184
-
185
- /** Per-token-id revocation for capability tokens (granular: one token dies, siblings live). */
186
- export class CapRevocationList {
187
- private revoked = new Set<string>(); // token ids; dynamic membership → Set
188
- revoke(tokenId: string): void {
189
- this.revoked.add(tokenId);
190
- }
191
- isRevoked(tokenId: string): boolean {
192
- return this.revoked.has(tokenId);
193
- }
194
- get size(): number {
195
- return this.revoked.size;
196
- }
197
- }
198
-
199
- export function verifyCapToken(
200
- publicKeyPem: string,
201
- token: CapToken,
202
- scope: string,
203
- revocations?: CapRevocationList,
204
- now = Date.now(),
205
- ): boolean {
206
- if (!token.signature) return false;
207
- if (!token.id) return false; // id-less legacy token: fail closed, re-mint
208
- if (token.expiresAt <= token.issuedAt) return false;
209
- if (token.notBefore !== undefined && now < token.notBefore) return false;
210
- if (now > token.expiresAt) return false;
211
- if (revocations?.isRevoked(token.id)) return false;
212
- const { signature, ...core } = token;
213
- if (!verifyBytes(publicKeyPem, canonicalCapToken(core), signature)) return false;
214
- return token.scopes.includes(scope);
215
- }
216
-
217
- export type AuthorizeVerdict = { ok: true } | { ok: false; reason: string };
218
-
219
- /**
220
- * Authorize a relay op against a capability token: device tombstone first,
221
- * then per-token-id revocation, signature, expiry, and scope — in that order
222
- * so revoked callers never reach crypto. This is the authorize path the relay
223
- * mirrors (see WsRelayServer.authorize in src/relay.ts, read-only here).
224
- */
225
- export function authorizeCapToken(opts: {
226
- publicKeyPem: string | undefined;
227
- token: CapToken | undefined;
228
- scope: string;
229
- revocations?: CapRevocationList;
230
- revokedDevices?: Set<string> | string[];
231
- now?: number;
232
- }): AuthorizeVerdict {
233
- const now = opts.now ?? Date.now();
234
- if (!opts.token) return { ok: false, reason: 'missing capability token' };
235
- const revoked = opts.revokedDevices instanceof Set ? opts.revokedDevices : new Set(opts.revokedDevices ?? []);
236
- if (revoked.has(opts.token.deviceId)) return { ok: false, reason: `device revoked: ${opts.token.deviceId}` };
237
- if (!opts.publicKeyPem) return { ok: false, reason: `unknown device: ${opts.token.deviceId}` };
238
- if (!verifyCapToken(opts.publicKeyPem, opts.token, opts.scope, opts.revocations, now)) {
239
- return { ok: false, reason: `capability rejected for ${opts.scope}` };
240
- }
241
- return { ok: true };
242
- }
243
-
244
- /**
245
- * Authorize an entry-scoped op against an authority-signed grant. Wires the
246
- * previously call-site-free verifyGrant into the authorize path so entry
247
- * scopes (entries:append, entries:resolve) are gated per grant id, not assumed.
248
- */
249
- export function authorizeGrant(opts: {
250
- authorityPublicPem: string;
251
- grant: ScopeGrant | undefined;
252
- scope: string;
253
- revocations?: RevocationList;
254
- now?: number;
255
- }): AuthorizeVerdict {
256
- const now = opts.now ?? Date.now();
257
- if (!opts.grant) return { ok: false, reason: 'missing scope grant' };
258
- if (!verifyGrant(opts.authorityPublicPem, opts.grant, opts.scope, opts.revocations, now)) {
259
- return { ok: false, reason: `grant rejected for ${opts.scope}` };
260
- }
261
- return { ok: true };
262
- }
263
-
264
- // Countersign: high-value moves need ≥ threshold distinct authorized signatures.
265
-
266
- export interface Countersignature {
267
- deviceId: string;
268
- signatureHex: string;
269
- }
270
-
271
- export function countersignEvent(privateKeyPem: string, deviceId: string, ev: LogEvent): Countersignature {
272
- return { deviceId, signatureHex: signEvent(privateKeyPem, ev) };
273
- }
274
-
275
- export function checkThreshold(
276
- registry: Map<string, string>, // deviceId -> publicKeyPem
277
- ev: LogEvent,
278
- signatures: Countersignature[],
279
- threshold: number,
280
- ): { valid: number; thresholdMet: boolean } {
281
- if (!Number.isInteger(threshold) || threshold < 1 || threshold > registry.size) {
282
- throw new RangeError(`checkThreshold: threshold ${threshold} out of range 1..${registry.size}`);
283
- }
284
- const seen = new Set<string>();
285
- let valid = 0;
286
- for (const s of signatures) {
287
- if (seen.has(s.deviceId)) continue; // one vote per device
288
- const pub = registry.get(s.deviceId);
289
- if (!pub) continue; // unknown device: not a vote
290
- if (!verifyEvent(pub, ev, s.signatureHex)) continue;
291
- seen.add(s.deviceId);
292
- valid += 1;
293
- }
294
- return { valid, thresholdMet: valid >= threshold };
295
- }
296
-
297
- export { canonicalOf };
1
+ // auth.ts — ed25519 device keys, revocable scopes, countersign threshold.
2
+ // No PKI: devices are raw public keys; an authority key signs scope grants.
3
+ import { generateKeyPairSync, randomUUID, sign, verify } from 'node:crypto';
4
+ import { canonicalOf, hashFor, type LogEvent } from './log.js';
5
+ /**
6
+ * Device identity.
7
+ *
8
+ * `deviceId` is the lowercase hex encoding of the ed25519 public key's
9
+ * SPKI DER bytes (`publicKey.export({ type: 'spki', format: 'der' })`
10
+ * rendered as hex). Registry maps, scope grants, capability tokens, and
11
+ * countersignatures key on this exact string — never the PEM. An explicit
12
+ * `deviceId` override (e.g. a named authority such as 'hq' in tests)
13
+ * bypasses the derivation and is NOT SPKI-DER hex; production devices
14
+ * always use the derived form.
15
+ */
16
+ export interface DeviceKeypair {
17
+ deviceId: string; // lowercase hex of the ed25519 public key (SPKI DER)
18
+ publicKeyPem: string;
19
+ privateKeyPem: string;
20
+ }
21
+
22
+ export function generateDeviceKey(deviceId?: string): DeviceKeypair {
23
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
24
+ const pubDer = publicKey.export({ type: 'spki', format: 'der' });
25
+ const pubPem = publicKey.export({ type: 'spki', format: 'pem' }) as string;
26
+ const privPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string;
27
+ return {
28
+ deviceId: deviceId ?? (pubDer as Buffer).toString('hex'),
29
+ publicKeyPem: pubPem,
30
+ privateKeyPem: privPem,
31
+ };
32
+ }
33
+
34
+ export function signBytes(privateKeyPem: string, data: Uint8Array | string): string {
35
+ const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data);
36
+ return sign(null, bytes, privateKeyPem).toString('hex');
37
+ }
38
+
39
+ export function verifyBytes(publicKeyPem: string, data: Uint8Array | string, signatureHex: string): boolean {
40
+ try {
41
+ const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data);
42
+ return verify(null, bytes, publicKeyPem, Buffer.from(signatureHex, 'hex'));
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /** Sign the event's hash-chain hash: signature covers the whole causal history. */
49
+ export function signEvent(privateKeyPem: string, ev: LogEvent): string {
50
+ return signBytes(privateKeyPem, ev.hash);
51
+ }
52
+
53
+ export function verifyEvent(publicKeyPem: string, ev: LogEvent, signatureHex: string): boolean {
54
+ const { hash } = ev;
55
+ // Recompute the chain hash so a signature can't be transplanted onto edited bytes.
56
+ // The auth envelope itself is never hashed (signature covers the hash).
57
+ const { hash: _drop, signature: _s, countersignatures: _c, ...core } = ev;
58
+ void _drop;
59
+ void _s;
60
+ void _c;
61
+ if (hashFor(core) !== hash) return false;
62
+ return verifyBytes(publicKeyPem, hash, signatureHex);
63
+ }
64
+
65
+ // Named TTLs: single source for grant/capability lifetimes. The relay path
66
+ // uses the short capability default; callers needing longer sessions pass
67
+ // an explicit ttlMs rather than forking a second magic number.
68
+ export const GRANT_TTL_MS = 24 * 3600 * 1000;
69
+ export const CAP_TOKEN_TTL_MS = 15 * 60 * 1000;
70
+
71
+ // Scopes: revocable capability grants signed by an authority key.
72
+ export interface ScopeGrant {
73
+ id: string;
74
+ deviceId: string;
75
+ scopes: string[]; // e.g. ['entries:append', 'entries:resolve']
76
+ issuedBy: string; // authority deviceId / name
77
+ issuedAt: number;
78
+ expiresAt: number;
79
+ signature?: string; // authority signature over the canonical grant
80
+ }
81
+
82
+ export function canonicalGrant(g: Omit<ScopeGrant, 'signature'>): string {
83
+ return JSON.stringify({
84
+ id: g.id,
85
+ deviceId: g.deviceId,
86
+ scopes: [...g.scopes].sort(),
87
+ issuedBy: g.issuedBy,
88
+ issuedAt: g.issuedAt,
89
+ expiresAt: g.expiresAt,
90
+ });
91
+ }
92
+
93
+ export function issueGrant(
94
+ authorityPrivatePem: string,
95
+ issuedBy: string,
96
+ deviceId: string,
97
+ scopes: string[],
98
+ ttlMs = GRANT_TTL_MS,
99
+ now = Date.now(),
100
+ ): ScopeGrant {
101
+ const grant: Omit<ScopeGrant, 'signature'> = {
102
+ id: randomUUID(),
103
+ deviceId,
104
+ scopes,
105
+ issuedBy,
106
+ issuedAt: now,
107
+ expiresAt: now + ttlMs,
108
+ };
109
+ return { ...grant, signature: signBytes(authorityPrivatePem, canonicalGrant(grant)) };
110
+ }
111
+
112
+ export class RevocationList {
113
+ private revoked = new Set<string>(); // grant ids; dynamic membership → Set
114
+ revoke(grantId: string): void {
115
+ this.revoked.add(grantId);
116
+ }
117
+ isRevoked(grantId: string): boolean {
118
+ return this.revoked.has(grantId);
119
+ }
120
+ get size(): number {
121
+ return this.revoked.size;
122
+ }
123
+ }
124
+
125
+ export function verifyGrant(
126
+ authorityPublicPem: string,
127
+ grant: ScopeGrant,
128
+ scope: string,
129
+ revocations?: RevocationList,
130
+ now = Date.now(),
131
+ ): boolean {
132
+ if (!grant.signature) return false;
133
+ if (grant.expiresAt <= grant.issuedAt) return false; // malformed lifetime: fail closed
134
+ if (now < grant.issuedAt) return false; // not-before: usable only from issuance
135
+ if (now > grant.expiresAt) return false;
136
+ if (revocations?.isRevoked(grant.id)) return false;
137
+ const { signature, ...core } = grant;
138
+ if (!verifyBytes(authorityPublicPem, canonicalGrant(core), signature)) return false;
139
+ return grant.scopes.includes(scope);
140
+ }
141
+
142
+ // Capability tokens: the device key itself signs a scope+expiry token.
143
+ // The relay holds a deviceId -> publicKey registry and verifies the
144
+ // signature + scope + expiry on every push/pull. No authority key involved.
145
+ // Self-signed limit: possession of a valid token equals the device key for
146
+ // its scopes until expiry or revocation — see docs/capability-token.md.
147
+
148
+ export interface CapToken {
149
+ id: string; // per-token id: revocation is granular, never whole-device only
150
+ deviceId: string;
151
+ scopes: string[]; // e.g. ['relay:push', 'relay:pull']
152
+ issuedAt: number;
153
+ expiresAt: number;
154
+ notBefore?: number; // optional activation floor: now < notBefore → not yet valid
155
+ signature?: string; // device signature over the canonical token
156
+ }
157
+ export function canonicalCapToken(t: Omit<CapToken, 'signature'>): string {
158
+ return JSON.stringify({
159
+ id: t.id,
160
+ deviceId: t.deviceId,
161
+ scopes: [...t.scopes].sort(),
162
+ issuedAt: t.issuedAt,
163
+ expiresAt: t.expiresAt,
164
+ ...(t.notBefore !== undefined ? { notBefore: t.notBefore } : {}),
165
+ });
166
+ }
167
+
168
+ export function mintCapToken(
169
+ privateKeyPem: string,
170
+ deviceId: string,
171
+ scopes: string[],
172
+ ttlMs = CAP_TOKEN_TTL_MS,
173
+ now = Date.now(),
174
+ ): CapToken {
175
+ const core: Omit<CapToken, 'signature'> = {
176
+ id: randomUUID(),
177
+ deviceId,
178
+ scopes,
179
+ issuedAt: now,
180
+ expiresAt: now + ttlMs,
181
+ };
182
+ return { ...core, signature: signBytes(privateKeyPem, canonicalCapToken(core)) };
183
+ }
184
+
185
+ /** Per-token-id revocation for capability tokens (granular: one token dies, siblings live). */
186
+ export class CapRevocationList {
187
+ private revoked = new Set<string>(); // token ids; dynamic membership → Set
188
+ revoke(tokenId: string): void {
189
+ this.revoked.add(tokenId);
190
+ }
191
+ isRevoked(tokenId: string): boolean {
192
+ return this.revoked.has(tokenId);
193
+ }
194
+ get size(): number {
195
+ return this.revoked.size;
196
+ }
197
+ }
198
+
199
+ export function verifyCapToken(
200
+ publicKeyPem: string,
201
+ token: CapToken,
202
+ scope: string,
203
+ revocations?: CapRevocationList,
204
+ now = Date.now(),
205
+ ): boolean {
206
+ if (!token.signature) return false;
207
+ if (!token.id) return false; // id-less legacy token: fail closed, re-mint
208
+ if (token.expiresAt <= token.issuedAt) return false;
209
+ if (token.notBefore !== undefined && now < token.notBefore) return false;
210
+ if (now > token.expiresAt) return false;
211
+ if (revocations?.isRevoked(token.id)) return false;
212
+ const { signature, ...core } = token;
213
+ if (!verifyBytes(publicKeyPem, canonicalCapToken(core), signature)) return false;
214
+ return token.scopes.includes(scope);
215
+ }
216
+
217
+ export type AuthorizeVerdict = { ok: true } | { ok: false; reason: string };
218
+
219
+ /**
220
+ * Authorize a relay op against a capability token: device tombstone first,
221
+ * then per-token-id revocation, signature, expiry, and scope — in that order
222
+ * so revoked callers never reach crypto. This is the authorize path the relay
223
+ * mirrors (see WsRelayServer.authorize in src/relay.ts, read-only here).
224
+ */
225
+ export function authorizeCapToken(opts: {
226
+ publicKeyPem: string | undefined;
227
+ token: CapToken | undefined;
228
+ scope: string;
229
+ revocations?: CapRevocationList;
230
+ revokedDevices?: Set<string> | string[];
231
+ now?: number;
232
+ }): AuthorizeVerdict {
233
+ const now = opts.now ?? Date.now();
234
+ if (!opts.token) return { ok: false, reason: 'missing capability token' };
235
+ const revoked = opts.revokedDevices instanceof Set ? opts.revokedDevices : new Set(opts.revokedDevices ?? []);
236
+ if (revoked.has(opts.token.deviceId)) return { ok: false, reason: `device revoked: ${opts.token.deviceId}` };
237
+ if (!opts.publicKeyPem) return { ok: false, reason: `unknown device: ${opts.token.deviceId}` };
238
+ if (!verifyCapToken(opts.publicKeyPem, opts.token, opts.scope, opts.revocations, now)) {
239
+ return { ok: false, reason: `capability rejected for ${opts.scope}` };
240
+ }
241
+ return { ok: true };
242
+ }
243
+
244
+ /**
245
+ * Authorize an entry-scoped op against an authority-signed grant. Wires the
246
+ * previously call-site-free verifyGrant into the authorize path so entry
247
+ * scopes (entries:append, entries:resolve) are gated per grant id, not assumed.
248
+ */
249
+ export function authorizeGrant(opts: {
250
+ authorityPublicPem: string;
251
+ grant: ScopeGrant | undefined;
252
+ scope: string;
253
+ revocations?: RevocationList;
254
+ now?: number;
255
+ }): AuthorizeVerdict {
256
+ const now = opts.now ?? Date.now();
257
+ if (!opts.grant) return { ok: false, reason: 'missing scope grant' };
258
+ if (!verifyGrant(opts.authorityPublicPem, opts.grant, opts.scope, opts.revocations, now)) {
259
+ return { ok: false, reason: `grant rejected for ${opts.scope}` };
260
+ }
261
+ return { ok: true };
262
+ }
263
+
264
+ // Countersign: high-value moves need ≥ threshold distinct authorized signatures.
265
+
266
+ export interface Countersignature {
267
+ deviceId: string;
268
+ signatureHex: string;
269
+ }
270
+
271
+ export function countersignEvent(privateKeyPem: string, deviceId: string, ev: LogEvent): Countersignature {
272
+ return { deviceId, signatureHex: signEvent(privateKeyPem, ev) };
273
+ }
274
+
275
+ export function checkThreshold(
276
+ registry: Map<string, string>, // deviceId -> publicKeyPem
277
+ ev: LogEvent,
278
+ signatures: Countersignature[],
279
+ threshold: number,
280
+ ): { valid: number; thresholdMet: boolean } {
281
+ if (!Number.isInteger(threshold) || threshold < 1 || threshold > registry.size) {
282
+ throw new RangeError(`checkThreshold: threshold ${threshold} out of range 1..${registry.size}`);
283
+ }
284
+ const seen = new Set<string>();
285
+ let valid = 0;
286
+ for (const s of signatures) {
287
+ if (seen.has(s.deviceId)) continue; // one vote per device
288
+ const pub = registry.get(s.deviceId);
289
+ if (!pub) continue; // unknown device: not a vote
290
+ if (!verifyEvent(pub, ev, s.signatureHex)) continue;
291
+ seen.add(s.deviceId);
292
+ valid += 1;
293
+ }
294
+ return { valid, thresholdMet: valid >= threshold };
295
+ }
296
+
297
+ export { canonicalOf };