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/revokelog.ts
CHANGED
|
@@ -1,291 +1,291 @@
|
|
|
1
|
-
// revokelog.ts — authenticated revoke event-log, convergent across relays.
|
|
2
|
-
//
|
|
3
|
-
// Closes two relay-revocation leaks: (1) revokes were unauthenticated local
|
|
4
|
-
// state (relay.ts keeps a bare Set<string> plus a live 'revoked' hint any
|
|
5
|
-
// relay can claim), (2) that set never converges across relays (per-relay
|
|
6
|
-
// sidecar file, no merge). Here every revoke is an admin-signed event and
|
|
7
|
-
// replicas sync by idempotent set-union merge, so merge is commutative and
|
|
8
|
-
// snapshots converge.
|
|
9
|
-
//
|
|
10
|
-
// Event JSON: { v, tokenId, deviceId, epoch, admin, issuedAt, prev, hash, sig, id }
|
|
11
|
-
// tokenId revoked capability/grant id; '*' = whole-device revoke.
|
|
12
|
-
// deviceId token owner whose capability dies.
|
|
13
|
-
// epoch monotonic per tokenId; higher supersedes lower.
|
|
14
|
-
// admin signer deviceId; must be in the trusted admin registry.
|
|
15
|
-
// issuedAt wall clock, display only — never authoritative.
|
|
16
|
-
// prev hash-chain link: REVOKE_GENESIS or a known event hash (audit hint).
|
|
17
|
-
// hash sha256 over the canonical core; id = hash (idempotency key).
|
|
18
|
-
// sig admin ed25519 signature over hash (auth.ts signBytes).
|
|
19
|
-
import { createHash } from 'node:crypto';
|
|
20
|
-
import { signBytes, verifyBytes } from './auth.js';
|
|
21
|
-
|
|
22
|
-
export const REVOKE_GENESIS = 'REVOKE-GENESIS';
|
|
23
|
-
export const REVOKE_V = 1;
|
|
24
|
-
|
|
25
|
-
export interface RevokeEvent {
|
|
26
|
-
v: 1;
|
|
27
|
-
tokenId: string;
|
|
28
|
-
deviceId: string;
|
|
29
|
-
epoch: number;
|
|
30
|
-
admin: string;
|
|
31
|
-
issuedAt: number;
|
|
32
|
-
prev: string;
|
|
33
|
-
hash: string;
|
|
34
|
-
sig: string;
|
|
35
|
-
id: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface RevokeInput {
|
|
39
|
-
tokenId: string;
|
|
40
|
-
deviceId: string;
|
|
41
|
-
epoch: number;
|
|
42
|
-
issuedAt?: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export type AdminRegistry = Map<string, string> | Record<string, string>;
|
|
46
|
-
|
|
47
|
-
export function normalizeRegistry(reg?: AdminRegistry): Map<string, string> {
|
|
48
|
-
if (!reg) return new Map();
|
|
49
|
-
return reg instanceof Map ? new Map(reg) : new Map(Object.entries(reg));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
type RevokeCore = Omit<RevokeEvent, 'hash' | 'sig' | 'id'>;
|
|
53
|
-
|
|
54
|
-
/** Canonical bytes covered by the chain hash (hash/sig/id excluded). */
|
|
55
|
-
export function canonicalRevoke(e: RevokeCore): string {
|
|
56
|
-
return JSON.stringify({
|
|
57
|
-
v: e.v,
|
|
58
|
-
tokenId: e.tokenId,
|
|
59
|
-
deviceId: e.deviceId,
|
|
60
|
-
epoch: e.epoch,
|
|
61
|
-
admin: e.admin,
|
|
62
|
-
issuedAt: e.issuedAt,
|
|
63
|
-
prev: e.prev,
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function hashRevoke(e: RevokeCore): string {
|
|
68
|
-
return createHash('sha256').update(canonicalRevoke(e), 'utf8').digest('hex');
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function checkFields(e: RevokeCore): string | null {
|
|
72
|
-
if (e.v !== REVOKE_V) return `unsupported v ${e.v as number}`;
|
|
73
|
-
if (typeof e.tokenId !== 'string' || e.tokenId === '') return 'empty tokenId';
|
|
74
|
-
if (typeof e.deviceId !== 'string' || e.deviceId === '') return 'empty deviceId';
|
|
75
|
-
if (typeof e.admin !== 'string' || e.admin === '') return 'empty admin';
|
|
76
|
-
if (!Number.isInteger(e.epoch) || e.epoch < 0) return `bad epoch ${e.epoch as number}`;
|
|
77
|
-
if (typeof e.issuedAt !== 'number' || !Number.isFinite(e.issuedAt)) return 'bad issuedAt';
|
|
78
|
-
if (typeof e.prev !== 'string' || e.prev === '') return 'empty prev';
|
|
79
|
-
return null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Build + admin-sign one revoke; prev threads the author's tip (audit hint). */
|
|
83
|
-
export function createRevokeEvent(
|
|
84
|
-
adminPrivatePem: string,
|
|
85
|
-
admin: string,
|
|
86
|
-
input: RevokeInput,
|
|
87
|
-
prev: string = REVOKE_GENESIS,
|
|
88
|
-
now: number = Date.now(),
|
|
89
|
-
): RevokeEvent {
|
|
90
|
-
const core: RevokeCore = {
|
|
91
|
-
v: REVOKE_V,
|
|
92
|
-
tokenId: input.tokenId,
|
|
93
|
-
deviceId: input.deviceId,
|
|
94
|
-
epoch: input.epoch,
|
|
95
|
-
admin,
|
|
96
|
-
issuedAt: input.issuedAt ?? now,
|
|
97
|
-
prev,
|
|
98
|
-
};
|
|
99
|
-
const bad = checkFields(core);
|
|
100
|
-
if (bad) throw new Error(`revoke rejected: ${bad}`);
|
|
101
|
-
const hash = hashRevoke(core);
|
|
102
|
-
return { ...core, hash, sig: signBytes(adminPrivatePem, hash), id: hash };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function verifyOne(admins: Map<string, string>, e: RevokeEvent): string | null {
|
|
106
|
-
const bad = checkFields(e);
|
|
107
|
-
if (bad) return bad;
|
|
108
|
-
if (e.id !== e.hash) return 'id/hash split';
|
|
109
|
-
const { hash, sig, id, ...core } = e;
|
|
110
|
-
void id;
|
|
111
|
-
if (hashRevoke(core) !== hash) return 'hash mismatch (tampered payload?)';
|
|
112
|
-
const pub = admins.get(e.admin);
|
|
113
|
-
if (!pub) return `unknown admin ${e.admin}`;
|
|
114
|
-
if (!verifyBytes(pub, hash, sig)) return 'bad admin signature';
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** Single-event auth check: content hash + known admin + signature. */
|
|
119
|
-
export function verifyRevokeEvent(admins: AdminRegistry, e: RevokeEvent): boolean {
|
|
120
|
-
return verifyOne(normalizeRegistry(admins), e) === null;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export interface RevokeVerify {
|
|
124
|
-
ok: boolean;
|
|
125
|
-
at?: string;
|
|
126
|
-
reason?: string;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* DAG replay over a batch: every event authentic and every non-genesis prev
|
|
131
|
-
* pointing at a known hash. Order-free on purpose — concurrent writers fork
|
|
132
|
-
* the prev hint, so this checks linkage, not a single linear order.
|
|
133
|
-
*/
|
|
134
|
-
export function verifyRevokeChain(admins: AdminRegistry, events: Iterable<RevokeEvent>): RevokeVerify {
|
|
135
|
-
const reg = normalizeRegistry(admins);
|
|
136
|
-
const list = [...events];
|
|
137
|
-
for (const e of list) {
|
|
138
|
-
const bad = verifyOne(reg, e);
|
|
139
|
-
if (bad) return { ok: false, at: e.id, reason: bad };
|
|
140
|
-
}
|
|
141
|
-
const known = new Set(list.map((e) => e.hash));
|
|
142
|
-
for (const e of list) {
|
|
143
|
-
if (e.prev !== REVOKE_GENESIS && !known.has(e.prev)) {
|
|
144
|
-
return { ok: false, at: e.id, reason: `dangling prev ${e.prev.slice(0, 12)}` };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return { ok: true };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export interface MergeResult {
|
|
151
|
-
added: number;
|
|
152
|
-
skipped: number;
|
|
153
|
-
rejected: number;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export type AppendOutcome = 'added' | 'duplicate';
|
|
157
|
-
|
|
158
|
-
export class RevokeLog {
|
|
159
|
-
private admins: Map<string, string>;
|
|
160
|
-
private order: RevokeEvent[] = []; // first-seen order; diffSince slices this
|
|
161
|
-
private byHash = new Map<string, RevokeEvent>();
|
|
162
|
-
|
|
163
|
-
constructor(trustedAdmins?: AdminRegistry) {
|
|
164
|
-
this.admins = normalizeRegistry(trustedAdmins);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
get size(): number {
|
|
168
|
-
return this.order.length;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Live tip hash (REVOKE_GENESIS when empty); doubles as the next prev. */
|
|
172
|
-
get tip(): string {
|
|
173
|
-
return this.order.length > 0 ? this.order[this.order.length - 1].hash : REVOKE_GENESIS;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
addAdmin(deviceId: string, publicKeyPem: string): void {
|
|
177
|
-
this.admins.set(deviceId, publicKeyPem);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** Sign + append in one step; prev threads the local tip. */
|
|
181
|
-
create(adminPrivatePem: string, admin: string, input: RevokeInput, now: number = Date.now()): RevokeEvent {
|
|
182
|
-
const e = createRevokeEvent(adminPrivatePem, admin, input, this.tip, now);
|
|
183
|
-
this.append(e);
|
|
184
|
-
return { ...e };
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Verified single append; throws on forgery. Replay of a known hash is a no-op. */
|
|
188
|
-
append(e: RevokeEvent): AppendOutcome {
|
|
189
|
-
if (this.byHash.has(e.hash)) return 'duplicate';
|
|
190
|
-
const bad = verifyOne(this.admins, e);
|
|
191
|
-
if (bad) throw new Error(`revoke rejected: ${bad}`);
|
|
192
|
-
if (e.prev !== REVOKE_GENESIS && !this.byHash.has(e.prev)) {
|
|
193
|
-
throw new Error(`revoke rejected: dangling prev ${e.prev.slice(0, 12)}`);
|
|
194
|
-
}
|
|
195
|
-
const frozen = { ...e };
|
|
196
|
-
this.order.push(frozen);
|
|
197
|
-
this.byHash.set(frozen.hash, frozen);
|
|
198
|
-
return 'added';
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Canonical convergent view: sorted by (epoch, hash). Byte-equal across
|
|
203
|
-
* replicas after a full bidirectional merge, regardless of arrival order.
|
|
204
|
-
*/
|
|
205
|
-
snapshot(): RevokeEvent[] {
|
|
206
|
-
return [...this.order]
|
|
207
|
-
.sort((a, b) => a.epoch - b.epoch || (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0))
|
|
208
|
-
.map((e) => ({ ...e }));
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* First-seen suffix after cursor; cursor = order.length. Only diff your own
|
|
213
|
-
* log (it is append-only, so the cursor is stable); cross-replica sync is
|
|
214
|
-
* merge(snapshot) — set union needs no cursor.
|
|
215
|
-
*/
|
|
216
|
-
diffSince(cursor: number): { events: RevokeEvent[]; cursor: number } {
|
|
217
|
-
if (!Number.isInteger(cursor) || cursor < 0 || cursor > this.order.length) {
|
|
218
|
-
throw new Error(`revoke rejected: bad cursor ${cursor as number}`);
|
|
219
|
-
}
|
|
220
|
-
return { events: this.order.slice(cursor).map((e) => ({ ...e })), cursor: this.order.length };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Idempotent set-union merge: commutative, so A.merge(B) then B.merge(A)
|
|
225
|
-
* converges. Unordered batches resolve to a fixpoint (prev may arrive
|
|
226
|
-
* later in the same batch); forged or dangling events count as rejected
|
|
227
|
-
* and are never stored.
|
|
228
|
-
*/
|
|
229
|
-
merge(remote: Iterable<RevokeEvent>): MergeResult {
|
|
230
|
-
const res: MergeResult = { added: 0, skipped: 0, rejected: 0 };
|
|
231
|
-
const pending: RevokeEvent[] = [];
|
|
232
|
-
for (const e of remote) {
|
|
233
|
-
if (this.byHash.has(e.hash)) {
|
|
234
|
-
res.skipped += 1;
|
|
235
|
-
continue;
|
|
236
|
-
}
|
|
237
|
-
if (verifyOne(this.admins, e) !== null) {
|
|
238
|
-
res.rejected += 1;
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
pending.push(e);
|
|
242
|
-
}
|
|
243
|
-
let progress = true;
|
|
244
|
-
while (progress && pending.length > 0) {
|
|
245
|
-
progress = false;
|
|
246
|
-
for (let i = pending.length - 1; i >= 0; i--) {
|
|
247
|
-
const e = pending[i];
|
|
248
|
-
if (e.prev === REVOKE_GENESIS || this.byHash.has(e.prev)) {
|
|
249
|
-
pending.splice(i, 1);
|
|
250
|
-
const frozen = { ...e };
|
|
251
|
-
this.order.push(frozen);
|
|
252
|
-
this.byHash.set(frozen.hash, frozen);
|
|
253
|
-
res.added += 1;
|
|
254
|
-
progress = true;
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
res.rejected += pending.length;
|
|
259
|
-
return res;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
verify(): RevokeVerify {
|
|
263
|
-
return verifyRevokeChain(this.admins, this.order);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/** Effective state: revoked iff some event for tokenId has epoch >= tokenEpoch. */
|
|
267
|
-
isRevoked(tokenId: string, tokenEpoch = 0): boolean {
|
|
268
|
-
for (const e of this.order) {
|
|
269
|
-
if (e.tokenId === tokenId && e.epoch >= tokenEpoch) return true;
|
|
270
|
-
}
|
|
271
|
-
return false;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* One row per tokenId at its max epoch, sorted by tokenId. Epoch ties
|
|
276
|
-
* break by min event hash, mirroring snapshot()'s (epoch, hash) order,
|
|
277
|
-
* so arrival order never decides the winner across replicas.
|
|
278
|
-
*/
|
|
279
|
-
revokedTokens(): Array<{ tokenId: string; deviceId: string; epoch: number }> {
|
|
280
|
-
const top = new Map<string, { tokenId: string; deviceId: string; epoch: number; hash: string }>();
|
|
281
|
-
for (const e of this.order) {
|
|
282
|
-
const cur = top.get(e.tokenId);
|
|
283
|
-
if (!cur || e.epoch > cur.epoch || (e.epoch === cur.epoch && e.hash < cur.hash)) {
|
|
284
|
-
top.set(e.tokenId, { tokenId: e.tokenId, deviceId: e.deviceId, epoch: e.epoch, hash: e.hash });
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
return [...top.values()]
|
|
288
|
-
.map(({ tokenId, deviceId, epoch }) => ({ tokenId, deviceId, epoch }))
|
|
289
|
-
.sort((a, b) => (a.tokenId < b.tokenId ? -1 : 1));
|
|
290
|
-
}
|
|
291
|
-
}
|
|
1
|
+
// revokelog.ts — authenticated revoke event-log, convergent across relays.
|
|
2
|
+
//
|
|
3
|
+
// Closes two relay-revocation leaks: (1) revokes were unauthenticated local
|
|
4
|
+
// state (relay.ts keeps a bare Set<string> plus a live 'revoked' hint any
|
|
5
|
+
// relay can claim), (2) that set never converges across relays (per-relay
|
|
6
|
+
// sidecar file, no merge). Here every revoke is an admin-signed event and
|
|
7
|
+
// replicas sync by idempotent set-union merge, so merge is commutative and
|
|
8
|
+
// snapshots converge.
|
|
9
|
+
//
|
|
10
|
+
// Event JSON: { v, tokenId, deviceId, epoch, admin, issuedAt, prev, hash, sig, id }
|
|
11
|
+
// tokenId revoked capability/grant id; '*' = whole-device revoke.
|
|
12
|
+
// deviceId token owner whose capability dies.
|
|
13
|
+
// epoch monotonic per tokenId; higher supersedes lower.
|
|
14
|
+
// admin signer deviceId; must be in the trusted admin registry.
|
|
15
|
+
// issuedAt wall clock, display only — never authoritative.
|
|
16
|
+
// prev hash-chain link: REVOKE_GENESIS or a known event hash (audit hint).
|
|
17
|
+
// hash sha256 over the canonical core; id = hash (idempotency key).
|
|
18
|
+
// sig admin ed25519 signature over hash (auth.ts signBytes).
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { signBytes, verifyBytes } from './auth.js';
|
|
21
|
+
|
|
22
|
+
export const REVOKE_GENESIS = 'REVOKE-GENESIS';
|
|
23
|
+
export const REVOKE_V = 1;
|
|
24
|
+
|
|
25
|
+
export interface RevokeEvent {
|
|
26
|
+
v: 1;
|
|
27
|
+
tokenId: string;
|
|
28
|
+
deviceId: string;
|
|
29
|
+
epoch: number;
|
|
30
|
+
admin: string;
|
|
31
|
+
issuedAt: number;
|
|
32
|
+
prev: string;
|
|
33
|
+
hash: string;
|
|
34
|
+
sig: string;
|
|
35
|
+
id: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RevokeInput {
|
|
39
|
+
tokenId: string;
|
|
40
|
+
deviceId: string;
|
|
41
|
+
epoch: number;
|
|
42
|
+
issuedAt?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AdminRegistry = Map<string, string> | Record<string, string>;
|
|
46
|
+
|
|
47
|
+
export function normalizeRegistry(reg?: AdminRegistry): Map<string, string> {
|
|
48
|
+
if (!reg) return new Map();
|
|
49
|
+
return reg instanceof Map ? new Map(reg) : new Map(Object.entries(reg));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type RevokeCore = Omit<RevokeEvent, 'hash' | 'sig' | 'id'>;
|
|
53
|
+
|
|
54
|
+
/** Canonical bytes covered by the chain hash (hash/sig/id excluded). */
|
|
55
|
+
export function canonicalRevoke(e: RevokeCore): string {
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
v: e.v,
|
|
58
|
+
tokenId: e.tokenId,
|
|
59
|
+
deviceId: e.deviceId,
|
|
60
|
+
epoch: e.epoch,
|
|
61
|
+
admin: e.admin,
|
|
62
|
+
issuedAt: e.issuedAt,
|
|
63
|
+
prev: e.prev,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function hashRevoke(e: RevokeCore): string {
|
|
68
|
+
return createHash('sha256').update(canonicalRevoke(e), 'utf8').digest('hex');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkFields(e: RevokeCore): string | null {
|
|
72
|
+
if (e.v !== REVOKE_V) return `unsupported v ${e.v as number}`;
|
|
73
|
+
if (typeof e.tokenId !== 'string' || e.tokenId === '') return 'empty tokenId';
|
|
74
|
+
if (typeof e.deviceId !== 'string' || e.deviceId === '') return 'empty deviceId';
|
|
75
|
+
if (typeof e.admin !== 'string' || e.admin === '') return 'empty admin';
|
|
76
|
+
if (!Number.isInteger(e.epoch) || e.epoch < 0) return `bad epoch ${e.epoch as number}`;
|
|
77
|
+
if (typeof e.issuedAt !== 'number' || !Number.isFinite(e.issuedAt)) return 'bad issuedAt';
|
|
78
|
+
if (typeof e.prev !== 'string' || e.prev === '') return 'empty prev';
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Build + admin-sign one revoke; prev threads the author's tip (audit hint). */
|
|
83
|
+
export function createRevokeEvent(
|
|
84
|
+
adminPrivatePem: string,
|
|
85
|
+
admin: string,
|
|
86
|
+
input: RevokeInput,
|
|
87
|
+
prev: string = REVOKE_GENESIS,
|
|
88
|
+
now: number = Date.now(),
|
|
89
|
+
): RevokeEvent {
|
|
90
|
+
const core: RevokeCore = {
|
|
91
|
+
v: REVOKE_V,
|
|
92
|
+
tokenId: input.tokenId,
|
|
93
|
+
deviceId: input.deviceId,
|
|
94
|
+
epoch: input.epoch,
|
|
95
|
+
admin,
|
|
96
|
+
issuedAt: input.issuedAt ?? now,
|
|
97
|
+
prev,
|
|
98
|
+
};
|
|
99
|
+
const bad = checkFields(core);
|
|
100
|
+
if (bad) throw new Error(`revoke rejected: ${bad}`);
|
|
101
|
+
const hash = hashRevoke(core);
|
|
102
|
+
return { ...core, hash, sig: signBytes(adminPrivatePem, hash), id: hash };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function verifyOne(admins: Map<string, string>, e: RevokeEvent): string | null {
|
|
106
|
+
const bad = checkFields(e);
|
|
107
|
+
if (bad) return bad;
|
|
108
|
+
if (e.id !== e.hash) return 'id/hash split';
|
|
109
|
+
const { hash, sig, id, ...core } = e;
|
|
110
|
+
void id;
|
|
111
|
+
if (hashRevoke(core) !== hash) return 'hash mismatch (tampered payload?)';
|
|
112
|
+
const pub = admins.get(e.admin);
|
|
113
|
+
if (!pub) return `unknown admin ${e.admin}`;
|
|
114
|
+
if (!verifyBytes(pub, hash, sig)) return 'bad admin signature';
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Single-event auth check: content hash + known admin + signature. */
|
|
119
|
+
export function verifyRevokeEvent(admins: AdminRegistry, e: RevokeEvent): boolean {
|
|
120
|
+
return verifyOne(normalizeRegistry(admins), e) === null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface RevokeVerify {
|
|
124
|
+
ok: boolean;
|
|
125
|
+
at?: string;
|
|
126
|
+
reason?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* DAG replay over a batch: every event authentic and every non-genesis prev
|
|
131
|
+
* pointing at a known hash. Order-free on purpose — concurrent writers fork
|
|
132
|
+
* the prev hint, so this checks linkage, not a single linear order.
|
|
133
|
+
*/
|
|
134
|
+
export function verifyRevokeChain(admins: AdminRegistry, events: Iterable<RevokeEvent>): RevokeVerify {
|
|
135
|
+
const reg = normalizeRegistry(admins);
|
|
136
|
+
const list = [...events];
|
|
137
|
+
for (const e of list) {
|
|
138
|
+
const bad = verifyOne(reg, e);
|
|
139
|
+
if (bad) return { ok: false, at: e.id, reason: bad };
|
|
140
|
+
}
|
|
141
|
+
const known = new Set(list.map((e) => e.hash));
|
|
142
|
+
for (const e of list) {
|
|
143
|
+
if (e.prev !== REVOKE_GENESIS && !known.has(e.prev)) {
|
|
144
|
+
return { ok: false, at: e.id, reason: `dangling prev ${e.prev.slice(0, 12)}` };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { ok: true };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface MergeResult {
|
|
151
|
+
added: number;
|
|
152
|
+
skipped: number;
|
|
153
|
+
rejected: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export type AppendOutcome = 'added' | 'duplicate';
|
|
157
|
+
|
|
158
|
+
export class RevokeLog {
|
|
159
|
+
private admins: Map<string, string>;
|
|
160
|
+
private order: RevokeEvent[] = []; // first-seen order; diffSince slices this
|
|
161
|
+
private byHash = new Map<string, RevokeEvent>();
|
|
162
|
+
|
|
163
|
+
constructor(trustedAdmins?: AdminRegistry) {
|
|
164
|
+
this.admins = normalizeRegistry(trustedAdmins);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
get size(): number {
|
|
168
|
+
return this.order.length;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Live tip hash (REVOKE_GENESIS when empty); doubles as the next prev. */
|
|
172
|
+
get tip(): string {
|
|
173
|
+
return this.order.length > 0 ? this.order[this.order.length - 1].hash : REVOKE_GENESIS;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
addAdmin(deviceId: string, publicKeyPem: string): void {
|
|
177
|
+
this.admins.set(deviceId, publicKeyPem);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Sign + append in one step; prev threads the local tip. */
|
|
181
|
+
create(adminPrivatePem: string, admin: string, input: RevokeInput, now: number = Date.now()): RevokeEvent {
|
|
182
|
+
const e = createRevokeEvent(adminPrivatePem, admin, input, this.tip, now);
|
|
183
|
+
this.append(e);
|
|
184
|
+
return { ...e };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Verified single append; throws on forgery. Replay of a known hash is a no-op. */
|
|
188
|
+
append(e: RevokeEvent): AppendOutcome {
|
|
189
|
+
if (this.byHash.has(e.hash)) return 'duplicate';
|
|
190
|
+
const bad = verifyOne(this.admins, e);
|
|
191
|
+
if (bad) throw new Error(`revoke rejected: ${bad}`);
|
|
192
|
+
if (e.prev !== REVOKE_GENESIS && !this.byHash.has(e.prev)) {
|
|
193
|
+
throw new Error(`revoke rejected: dangling prev ${e.prev.slice(0, 12)}`);
|
|
194
|
+
}
|
|
195
|
+
const frozen = { ...e };
|
|
196
|
+
this.order.push(frozen);
|
|
197
|
+
this.byHash.set(frozen.hash, frozen);
|
|
198
|
+
return 'added';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Canonical convergent view: sorted by (epoch, hash). Byte-equal across
|
|
203
|
+
* replicas after a full bidirectional merge, regardless of arrival order.
|
|
204
|
+
*/
|
|
205
|
+
snapshot(): RevokeEvent[] {
|
|
206
|
+
return [...this.order]
|
|
207
|
+
.sort((a, b) => a.epoch - b.epoch || (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0))
|
|
208
|
+
.map((e) => ({ ...e }));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* First-seen suffix after cursor; cursor = order.length. Only diff your own
|
|
213
|
+
* log (it is append-only, so the cursor is stable); cross-replica sync is
|
|
214
|
+
* merge(snapshot) — set union needs no cursor.
|
|
215
|
+
*/
|
|
216
|
+
diffSince(cursor: number): { events: RevokeEvent[]; cursor: number } {
|
|
217
|
+
if (!Number.isInteger(cursor) || cursor < 0 || cursor > this.order.length) {
|
|
218
|
+
throw new Error(`revoke rejected: bad cursor ${cursor as number}`);
|
|
219
|
+
}
|
|
220
|
+
return { events: this.order.slice(cursor).map((e) => ({ ...e })), cursor: this.order.length };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Idempotent set-union merge: commutative, so A.merge(B) then B.merge(A)
|
|
225
|
+
* converges. Unordered batches resolve to a fixpoint (prev may arrive
|
|
226
|
+
* later in the same batch); forged or dangling events count as rejected
|
|
227
|
+
* and are never stored.
|
|
228
|
+
*/
|
|
229
|
+
merge(remote: Iterable<RevokeEvent>): MergeResult {
|
|
230
|
+
const res: MergeResult = { added: 0, skipped: 0, rejected: 0 };
|
|
231
|
+
const pending: RevokeEvent[] = [];
|
|
232
|
+
for (const e of remote) {
|
|
233
|
+
if (this.byHash.has(e.hash)) {
|
|
234
|
+
res.skipped += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (verifyOne(this.admins, e) !== null) {
|
|
238
|
+
res.rejected += 1;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
pending.push(e);
|
|
242
|
+
}
|
|
243
|
+
let progress = true;
|
|
244
|
+
while (progress && pending.length > 0) {
|
|
245
|
+
progress = false;
|
|
246
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
247
|
+
const e = pending[i];
|
|
248
|
+
if (e.prev === REVOKE_GENESIS || this.byHash.has(e.prev)) {
|
|
249
|
+
pending.splice(i, 1);
|
|
250
|
+
const frozen = { ...e };
|
|
251
|
+
this.order.push(frozen);
|
|
252
|
+
this.byHash.set(frozen.hash, frozen);
|
|
253
|
+
res.added += 1;
|
|
254
|
+
progress = true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
res.rejected += pending.length;
|
|
259
|
+
return res;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
verify(): RevokeVerify {
|
|
263
|
+
return verifyRevokeChain(this.admins, this.order);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Effective state: revoked iff some event for tokenId has epoch >= tokenEpoch. */
|
|
267
|
+
isRevoked(tokenId: string, tokenEpoch = 0): boolean {
|
|
268
|
+
for (const e of this.order) {
|
|
269
|
+
if (e.tokenId === tokenId && e.epoch >= tokenEpoch) return true;
|
|
270
|
+
}
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* One row per tokenId at its max epoch, sorted by tokenId. Epoch ties
|
|
276
|
+
* break by min event hash, mirroring snapshot()'s (epoch, hash) order,
|
|
277
|
+
* so arrival order never decides the winner across replicas.
|
|
278
|
+
*/
|
|
279
|
+
revokedTokens(): Array<{ tokenId: string; deviceId: string; epoch: number }> {
|
|
280
|
+
const top = new Map<string, { tokenId: string; deviceId: string; epoch: number; hash: string }>();
|
|
281
|
+
for (const e of this.order) {
|
|
282
|
+
const cur = top.get(e.tokenId);
|
|
283
|
+
if (!cur || e.epoch > cur.epoch || (e.epoch === cur.epoch && e.hash < cur.hash)) {
|
|
284
|
+
top.set(e.tokenId, { tokenId: e.tokenId, deviceId: e.deviceId, epoch: e.epoch, hash: e.hash });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return [...top.values()]
|
|
288
|
+
.map(({ tokenId, deviceId, epoch }) => ({ tokenId, deviceId, epoch }))
|
|
289
|
+
.sort((a, b) => (a.tokenId < b.tokenId ? -1 : 1));
|
|
290
|
+
}
|
|
291
|
+
}
|