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/CHANGELOG.md +5 -0
- package/LICENSE +21 -21
- package/package.json +34 -34
- package/src/auth.ts +297 -297
- package/src/kernel.ts +333 -333
- package/src/revokelog.ts +291 -291
- package/src/store.ts +706 -706
- package/src/sync.ts +828 -828
- package/src/tombstone.ts +306 -306
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 };
|