ftown 0.21.0 → 0.24.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/dist/attention-events.d.ts +1 -1
- package/dist/attention-store.d.ts +3 -0
- package/dist/attention-store.js +25 -1
- package/dist/attention-store.js.map +1 -1
- package/dist/centrifugo-client.d.ts +14 -1
- package/dist/centrifugo-client.js +23 -2
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/command-rpc.d.ts +2 -0
- package/dist/command-rpc.js +55 -9
- package/dist/command-rpc.js.map +1 -1
- package/dist/identity-reset.d.ts +3 -1
- package/dist/identity-reset.js +27 -5
- package/dist/identity-reset.js.map +1 -1
- package/dist/index.js +15 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger-pairing-client.d.ts +1 -1
- package/dist/ledger-pairing-client.js +5 -3
- package/dist/ledger-pairing-client.js.map +1 -1
- package/dist/ledger-relay.d.ts +42 -7
- package/dist/ledger-relay.js +175 -33
- package/dist/ledger-relay.js.map +1 -1
- package/dist/ledger-reoffer.d.ts +56 -0
- package/dist/ledger-reoffer.js +210 -0
- package/dist/ledger-reoffer.js.map +1 -0
- package/dist/ledger-startup.d.ts +30 -5
- package/dist/ledger-startup.js +40 -10
- package/dist/ledger-startup.js.map +1 -1
- package/dist/local-api-server.d.ts +3 -0
- package/dist/local-api-server.js +68 -6
- package/dist/local-api-server.js.map +1 -1
- package/dist/mail-delivery.d.ts +26 -3
- package/dist/mail-delivery.js +65 -7
- package/dist/mail-delivery.js.map +1 -1
- package/dist/preview-protocol.d.ts +19 -3
- package/dist/preview-protocol.js +14 -0
- package/dist/preview-protocol.js.map +1 -1
- package/dist/relay-token.d.ts +6 -0
- package/dist/relay-token.js +9 -1
- package/dist/relay-token.js.map +1 -1
- package/dist/security/bridge/ledger-rpc.d.ts +34 -0
- package/dist/security/bridge/ledger-rpc.js +58 -0
- package/dist/security/bridge/ledger-rpc.js.map +1 -0
- package/dist/security/bridge/ledger-store.d.ts +82 -13
- package/dist/security/bridge/ledger-store.js +394 -54
- package/dist/security/bridge/ledger-store.js.map +1 -1
- package/dist/security/bridge/permissions.js +2 -1
- package/dist/security/bridge/permissions.js.map +1 -1
- package/dist/security/bridge/policy.d.ts +2 -1
- package/dist/security/bridge/policy.js.map +1 -1
- package/dist/security/bridge/security.d.ts +138 -10
- package/dist/security/bridge/security.js +252 -54
- package/dist/security/bridge/security.js.map +1 -1
- package/dist/security/ledger/announce.d.ts +32 -0
- package/dist/security/ledger/announce.js +101 -0
- package/dist/security/ledger/announce.js.map +1 -0
- package/dist/security/ledger/ledger.d.ts +107 -14
- package/dist/security/ledger/ledger.js +364 -83
- package/dist/security/ledger/ledger.js.map +1 -1
- package/dist/security/ledger/proof.d.ts +12 -0
- package/dist/security/ledger/proof.js +40 -8
- package/dist/security/ledger/proof.js.map +1 -1
- package/dist/security/ledger/protocol.d.ts +14 -1
- package/dist/security/ledger/protocol.js.map +1 -1
- package/dist/session-controller.d.ts +9 -3
- package/dist/session-controller.js +120 -20
- package/dist/session-controller.js.map +1 -1
- package/dist/session-inbox-protocol.d.ts +2 -0
- package/dist/session-preview.d.ts +20 -5
- package/dist/session-preview.js +185 -37
- package/dist/session-preview.js.map +1 -1
- package/dist/session-store.js +19 -1
- package/dist/session-store.js.map +1 -1
- package/dist/sessions-commands.js +41 -7
- package/dist/sessions-commands.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/opencode-plugin/ftown.js +15 -2
- package/package.json +2 -2
- package/skills/ftown/SKILL.md +18 -7
- package/skills/ftown/references/sessions.md +35 -10
|
@@ -5,6 +5,7 @@ const messages = {
|
|
|
5
5
|
ledger_other_account: 'Account ledger entry belongs to a different account.',
|
|
6
6
|
ledger_revoked: 'Account ledger entry refers to a revoked key.',
|
|
7
7
|
ledger_limit: 'Account ledger reached its supported capacity.',
|
|
8
|
+
resolution_incomplete: 'Account ledger resolution must first carry the other history\'s removals and passkeys.',
|
|
8
9
|
};
|
|
9
10
|
/** No untrusted text, key material or underlying crypto error enters this error. */
|
|
10
11
|
export class LedgerError extends Error {
|
|
@@ -166,6 +167,13 @@ function parseOperation(value) {
|
|
|
166
167
|
const fields = exact(record, ['type', 'authority', 'consent']);
|
|
167
168
|
return { type: 'add_authority', authority: parseAuthority(fields.authority), consent: parseSignature(fields.consent) };
|
|
168
169
|
}
|
|
170
|
+
case 'resolve_fork': {
|
|
171
|
+
const fields = exact(record, ['type', 'forkHead', 'discardedFirst', 'discardedHead', 'discardedRevision']);
|
|
172
|
+
return {
|
|
173
|
+
type: 'resolve_fork', forkHead: fingerprint(fields.forkHead), discardedFirst: fingerprint(fields.discardedFirst),
|
|
174
|
+
discardedHead: fingerprint(fields.discardedHead), discardedRevision: count(fields.discardedRevision, 1),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
169
177
|
default: return fail();
|
|
170
178
|
}
|
|
171
179
|
}
|
|
@@ -291,19 +299,25 @@ function apply(current, operation, issuedAt) {
|
|
|
291
299
|
next.bridges.push(added);
|
|
292
300
|
break;
|
|
293
301
|
}
|
|
302
|
+
// A revoked id is a permanent tombstone. Revoking an id that is not a member only tombstones it, so it can never be added later: that is
|
|
303
|
+
// how a resolution carries a removal made on the other history. A device revocation never names a bridge member's endpoint.
|
|
294
304
|
case 'revoke_device': {
|
|
305
|
+
if (next.revokedDeviceIds.indexOf(operation.endpointId) !== -1)
|
|
306
|
+
fail('ledger_revoked');
|
|
307
|
+
if (next.bridges.some(item => item.endpoint.id === operation.endpointId))
|
|
308
|
+
fail();
|
|
295
309
|
const index = next.devices.findIndex(item => item.endpoint.id === operation.endpointId);
|
|
296
|
-
if (index
|
|
297
|
-
|
|
298
|
-
next.devices.splice(index, 1);
|
|
310
|
+
if (index !== -1)
|
|
311
|
+
next.devices.splice(index, 1);
|
|
299
312
|
next.revokedDeviceIds.push(operation.endpointId);
|
|
300
313
|
break;
|
|
301
314
|
}
|
|
302
315
|
case 'revoke_bridge': {
|
|
316
|
+
if (next.revokedBridgeIds.indexOf(operation.bridgeId) !== -1)
|
|
317
|
+
fail('ledger_revoked');
|
|
303
318
|
const index = next.bridges.findIndex(item => item.bridgeId === operation.bridgeId);
|
|
304
|
-
if (index
|
|
305
|
-
|
|
306
|
-
next.bridges.splice(index, 1);
|
|
319
|
+
if (index !== -1)
|
|
320
|
+
next.bridges.splice(index, 1);
|
|
307
321
|
next.revokedBridgeIds.push(operation.bridgeId);
|
|
308
322
|
break;
|
|
309
323
|
}
|
|
@@ -318,10 +332,108 @@ function apply(current, operation, issuedAt) {
|
|
|
318
332
|
next.authorities.push(added);
|
|
319
333
|
break;
|
|
320
334
|
}
|
|
335
|
+
case 'resolve_fork': {
|
|
336
|
+
// Membership is unchanged. Only the rules that need no history are here; settle() checks the heads against the chain itself.
|
|
337
|
+
const own = [operation.forkHead, next.head, next.accountId];
|
|
338
|
+
if (own.indexOf(operation.discardedFirst) !== -1 || own.indexOf(operation.discardedHead) !== -1)
|
|
339
|
+
fail();
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
321
342
|
default: return fail();
|
|
322
343
|
}
|
|
323
344
|
return next;
|
|
324
345
|
}
|
|
346
|
+
function joinedAt(trail, credentialId) {
|
|
347
|
+
for (const item of trail.joined)
|
|
348
|
+
if (item.credentialId === credentialId)
|
|
349
|
+
return item.revision;
|
|
350
|
+
return -1;
|
|
351
|
+
}
|
|
352
|
+
/** History rules of resolve_fork, against the chain before the record: forkHead is one of its heads and the signer was an authority at it;
|
|
353
|
+
* the discarded branch (discardedFirst ... discardedHead at discardedRevision) starts after it and is no part of this chain; its first record
|
|
354
|
+
* is its tip exactly when it is one record long. */
|
|
355
|
+
function settle(trail, operation, credentialId) {
|
|
356
|
+
if (operation.type !== 'resolve_fork')
|
|
357
|
+
return;
|
|
358
|
+
const fork = trail.heads.indexOf(operation.forkHead);
|
|
359
|
+
if (fork === -1 || operation.discardedRevision <= fork)
|
|
360
|
+
fail();
|
|
361
|
+
if (trail.heads.indexOf(operation.discardedFirst) !== -1 || trail.heads.indexOf(operation.discardedHead) !== -1)
|
|
362
|
+
fail();
|
|
363
|
+
if ((operation.discardedRevision === fork + 1) !== (operation.discardedHead === operation.discardedFirst))
|
|
364
|
+
fail();
|
|
365
|
+
const joined = joinedAt(trail, credentialId);
|
|
366
|
+
if (joined === -1 || joined > fork)
|
|
367
|
+
fail();
|
|
368
|
+
}
|
|
369
|
+
/** Revoked ids and authorities of a chain from its payloads alone; equals its checkpoint's for a chain already verified or vouched for. */
|
|
370
|
+
function holdings(ledger) {
|
|
371
|
+
const held = { revokedDeviceIds: [], revokedBridgeIds: [], authorities: [ledger.genesis.payload.authority] };
|
|
372
|
+
for (const record of ledger.transitions) {
|
|
373
|
+
const operation = record.payload.operation;
|
|
374
|
+
if (operation.type === 'revoke_device')
|
|
375
|
+
held.revokedDeviceIds.push(operation.endpointId);
|
|
376
|
+
else if (operation.type === 'revoke_bridge')
|
|
377
|
+
held.revokedBridgeIds.push(operation.bridgeId);
|
|
378
|
+
else if (operation.type === 'add_authority')
|
|
379
|
+
held.authorities.push(operation.authority);
|
|
380
|
+
}
|
|
381
|
+
return held;
|
|
382
|
+
}
|
|
383
|
+
/** The security-relevant operations of `branch` that `held` lacks, in branch order: one revocation per id `held` has not revoked, and one
|
|
384
|
+
* add_authority (the original authority and consent) per authority `held` does not hold with the same credentialId and key. */
|
|
385
|
+
function missing(branch, held) {
|
|
386
|
+
const devices = held.revokedDeviceIds.slice();
|
|
387
|
+
const bridges = held.revokedBridgeIds.slice();
|
|
388
|
+
const authorities = held.authorities.slice();
|
|
389
|
+
const carry = [];
|
|
390
|
+
for (const record of branch) {
|
|
391
|
+
const operation = record.payload.operation;
|
|
392
|
+
if (operation.type === 'revoke_device' && devices.indexOf(operation.endpointId) === -1) {
|
|
393
|
+
devices.push(operation.endpointId);
|
|
394
|
+
carry.push({ type: 'revoke_device', endpointId: operation.endpointId });
|
|
395
|
+
}
|
|
396
|
+
else if (operation.type === 'revoke_bridge' && bridges.indexOf(operation.bridgeId) === -1) {
|
|
397
|
+
bridges.push(operation.bridgeId);
|
|
398
|
+
carry.push({ type: 'revoke_bridge', bridgeId: operation.bridgeId });
|
|
399
|
+
}
|
|
400
|
+
else if (operation.type === 'add_authority') {
|
|
401
|
+
const added = operation.authority;
|
|
402
|
+
if (authorities.some(item => item.credentialId === added.credentialId && item.publicKey === added.publicKey))
|
|
403
|
+
continue;
|
|
404
|
+
authorities.push(added);
|
|
405
|
+
carry.push({ type: 'add_authority', authority: { ...added }, consent: { ...operation.consent } });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return carry;
|
|
409
|
+
}
|
|
410
|
+
function note(trail, payload, head) {
|
|
411
|
+
trail.heads.push(head);
|
|
412
|
+
if (payload.operation.type === 'add_authority')
|
|
413
|
+
trail.joined.push({ credentialId: payload.operation.authority.credentialId, revision: payload.revision });
|
|
414
|
+
}
|
|
415
|
+
function initialState(root, accountId) {
|
|
416
|
+
return { version: 2, accountId, revision: 0, head: accountId, authorities: [root.authority], devices: [], bridges: [], revokedDeviceIds: [], revokedBridgeIds: [] };
|
|
417
|
+
}
|
|
418
|
+
function initialTrail(root, accountId) {
|
|
419
|
+
return { heads: [accountId], joined: [{ credentialId: root.authority.credentialId, revision: 0 }] };
|
|
420
|
+
}
|
|
421
|
+
/** The trail of the first `length` records of a chain already verified or vouched for; no signature work. */
|
|
422
|
+
function trailOf(chain, length) {
|
|
423
|
+
const trail = initialTrail(chain.ledger.genesis.payload, chain.accountId);
|
|
424
|
+
for (let index = 0; index < length; index += 1)
|
|
425
|
+
note(trail, chain.ledger.transitions[index].payload, chain.heads[index + 1]);
|
|
426
|
+
return trail;
|
|
427
|
+
}
|
|
428
|
+
/** The checkpoint after the first `length` records of a chain already verified or vouched for: the verifier's state rules, no signature work. */
|
|
429
|
+
function replay(chain, length) {
|
|
430
|
+
let state = initialState(chain.ledger.genesis.payload, chain.accountId);
|
|
431
|
+
for (let index = 0; index < length; index += 1) {
|
|
432
|
+
const payload = chain.ledger.transitions[index].payload;
|
|
433
|
+
state = { ...apply(state, payload.operation, payload.issuedAt), revision: payload.revision, head: chain.heads[index + 1] };
|
|
434
|
+
}
|
|
435
|
+
return state;
|
|
436
|
+
}
|
|
325
437
|
function copyOf(state) {
|
|
326
438
|
return {
|
|
327
439
|
version: 2, accountId: state.accountId, revision: state.revision, head: state.head,
|
|
@@ -335,8 +447,9 @@ async function consented(operation, accountId, root) {
|
|
|
335
447
|
if (!await verifyPasskeyAssertion({ rpId: root.rpId, origins: root.origins, publicKey: operation.authority.publicKey, challenge, signature: operation.consent }))
|
|
336
448
|
fail();
|
|
337
449
|
}
|
|
338
|
-
/** Verifies `records` in order on top of a verified `state` whose last record was issued at `previousTime
|
|
339
|
-
|
|
450
|
+
/** Verifies `records` in order on top of a verified `state` whose last record was issued at `previousTime`; `trail` describes the chain up
|
|
451
|
+
* to `state` and is extended in place. */
|
|
452
|
+
async function extend(state, root, previousTime, records, at, trail) {
|
|
340
453
|
let current = state;
|
|
341
454
|
let lastTime = previousTime;
|
|
342
455
|
for (const record of records) {
|
|
@@ -350,13 +463,16 @@ async function extend(state, root, previousTime, records, at) {
|
|
|
350
463
|
if (payload.issuedAt < lastTime || payload.issuedAt > at + LEDGER_LIMITS.maxFutureSkewMs)
|
|
351
464
|
fail();
|
|
352
465
|
const signer = signerOf(current, payload.credentialId);
|
|
466
|
+
settle(trail, payload.operation, signer.credentialId);
|
|
353
467
|
const challenge = await ledgerChallenge(payload);
|
|
354
468
|
if (!await verifyPasskeyAssertion({ rpId: root.rpId, origins: root.origins, publicKey: signer.publicKey, challenge, signature: record.signature }))
|
|
355
469
|
fail();
|
|
356
470
|
await keyMaterial(payload.operation);
|
|
357
471
|
if (payload.operation.type === 'add_authority')
|
|
358
472
|
await consented(payload.operation, current.accountId, root);
|
|
359
|
-
|
|
473
|
+
const head = b64url(challenge);
|
|
474
|
+
current = { ...apply(current, payload.operation, payload.issuedAt), revision: payload.revision, head };
|
|
475
|
+
note(trail, payload, head);
|
|
360
476
|
lastTime = payload.issuedAt;
|
|
361
477
|
}
|
|
362
478
|
return current;
|
|
@@ -370,10 +486,7 @@ async function verifyAll(ledger, at) {
|
|
|
370
486
|
if (!await verifyPasskeyAssertion({ rpId: root.rpId, origins: root.origins, publicKey: root.authority.publicKey, challenge, signature: ledger.genesis.signature }))
|
|
371
487
|
fail();
|
|
372
488
|
const accountId = b64url(challenge);
|
|
373
|
-
|
|
374
|
-
version: 2, accountId, revision: 0, head: accountId, authorities: [root.authority], devices: [], bridges: [], revokedDeviceIds: [], revokedBridgeIds: [],
|
|
375
|
-
};
|
|
376
|
-
return extend(initial, root, root.createdAt, ledger.transitions, at);
|
|
489
|
+
return extend(initialState(root, accountId), root, root.createdAt, ledger.transitions, at, initialTrail(root, accountId));
|
|
377
490
|
}
|
|
378
491
|
/** Unexpected (non-ledger) failures become invalid_ledger. */
|
|
379
492
|
async function guarded(run) {
|
|
@@ -388,11 +501,10 @@ async function guarded(run) {
|
|
|
388
501
|
}
|
|
389
502
|
async function indexed(value) {
|
|
390
503
|
const ledger = parseLedger(value);
|
|
391
|
-
const
|
|
392
|
-
const heads = [];
|
|
504
|
+
const heads = [await ledgerHead(ledger.genesis.payload)];
|
|
393
505
|
for (const record of ledger.transitions)
|
|
394
506
|
heads.push(await ledgerHead(record.payload));
|
|
395
|
-
return { ledger, accountId
|
|
507
|
+
return { ledger, accountId: heads[0], heads };
|
|
396
508
|
}
|
|
397
509
|
/** The cache vouches for `chain` only if it names the chain's last head and the chain is hash-linked up to it. */
|
|
398
510
|
function vouched(chain, cache) {
|
|
@@ -401,12 +513,13 @@ function vouched(chain, cache) {
|
|
|
401
513
|
try {
|
|
402
514
|
const record = object(cache);
|
|
403
515
|
const checkpoint = parseCheckpoint(record.checkpoint);
|
|
404
|
-
|
|
405
|
-
|
|
516
|
+
const head = chain.heads[chain.heads.length - 1];
|
|
517
|
+
if (record.head !== head || checkpoint.head !== head || checkpoint.accountId !== chain.accountId
|
|
518
|
+
|| checkpoint.revision !== chain.heads.length - 1)
|
|
406
519
|
return null;
|
|
407
|
-
for (let index = 0; index < chain.
|
|
520
|
+
for (let index = 0; index < chain.ledger.transitions.length; index += 1) {
|
|
408
521
|
const payload = chain.ledger.transitions[index].payload;
|
|
409
|
-
if (payload.revision !== index + 1 || payload.previousHead !==
|
|
522
|
+
if (payload.revision !== index + 1 || payload.previousHead !== chain.heads[index])
|
|
410
523
|
return null;
|
|
411
524
|
}
|
|
412
525
|
return checkpoint;
|
|
@@ -415,39 +528,110 @@ function vouched(chain, cache) {
|
|
|
415
528
|
return null;
|
|
416
529
|
}
|
|
417
530
|
}
|
|
418
|
-
|
|
419
|
-
|
|
531
|
+
/** Index of the first revision whose heads differ, or the shorter length when one list is a prefix of the other. String compares only. */
|
|
532
|
+
function firstDifference(local, candidate) {
|
|
533
|
+
const length = Math.min(local.length, candidate.length);
|
|
534
|
+
let index = 0;
|
|
535
|
+
while (index < length && local[index] === candidate[index])
|
|
536
|
+
index += 1;
|
|
537
|
+
return index;
|
|
538
|
+
}
|
|
539
|
+
/** null when even the genesis differs (another account). */
|
|
540
|
+
function classify(local, candidate) {
|
|
541
|
+
const index = firstDifference(local, candidate);
|
|
542
|
+
if (index === 0)
|
|
543
|
+
return null;
|
|
544
|
+
if (index < local.length && index < candidate.length)
|
|
545
|
+
return 'diverged';
|
|
546
|
+
if (local.length === candidate.length)
|
|
547
|
+
return 'same';
|
|
548
|
+
return candidate.length > local.length ? 'ahead' : 'behind';
|
|
549
|
+
}
|
|
550
|
+
function headList(value) {
|
|
551
|
+
if (!Array.isArray(value))
|
|
552
|
+
return fail();
|
|
553
|
+
const heads = value;
|
|
554
|
+
if (heads.length < 1 || heads.length > LEDGER_LIMITS.maxTransitions + 1 || heads.some(item => typeof item !== 'string'))
|
|
555
|
+
return fail();
|
|
556
|
+
return value;
|
|
420
557
|
}
|
|
421
|
-
/**
|
|
422
|
-
function
|
|
423
|
-
|
|
558
|
+
/** The fork revision of two diverged chains; ledger_other_account for another account, invalid_ledger for any other relation. */
|
|
559
|
+
function divergence(mine, theirs) {
|
|
560
|
+
const relation = classify(mine.heads, theirs.heads);
|
|
561
|
+
if (relation === null)
|
|
562
|
+
fail('ledger_other_account');
|
|
563
|
+
if (relation !== 'diverged')
|
|
564
|
+
fail();
|
|
565
|
+
return firstDifference(mine.heads, theirs.heads) - 1;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* True iff the verified diverged candidate (checkpoint `adopted`) may replace local's branch, i.e. local's records after `fork`:
|
|
569
|
+
* - the candidate already holds every revocation and every authority that local's branch makes (nothing security-relevant is discarded), and
|
|
570
|
+
* - it carries, after the fork point, a resolve_fork R with
|
|
571
|
+
* 1. R.forkHead = the fork point and R.discardedFirst = local's first record after it (so a lagging or sibling replica of that branch matches);
|
|
572
|
+
* 2. if local's branch holds any resolve_fork (whatever it targets), R.discardedHead = local's OWN record at R.discardedRevision, at or after
|
|
573
|
+
* the latest one: R's payload hashes over it, so its signer saw that choice. A revision number alone is not proof (a signer who saw a
|
|
574
|
+
* sibling branch at a higher revision never saw it). Each adoption thus moves to a resolution that hashes over the one held before, and
|
|
575
|
+
* as hashes cannot form a cycle, no replica ever adopts its way back to a chain it held;
|
|
576
|
+
* 3. no record of local's branch is newer than R: issuedAt <= R.issuedAt + maxFutureSkewMs (the module's clock allowance).
|
|
577
|
+
*/
|
|
578
|
+
function resolves(mine, theirs, fork, adopted) {
|
|
579
|
+
const branch = mine.ledger.transitions.slice(fork);
|
|
580
|
+
if (missing(branch, adopted).length)
|
|
424
581
|
return false;
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
582
|
+
let seen = 0;
|
|
583
|
+
let newest = 0;
|
|
584
|
+
for (const record of branch) {
|
|
585
|
+
if (record.payload.operation.type === 'resolve_fork')
|
|
586
|
+
seen = record.payload.revision;
|
|
587
|
+
newest = Math.max(newest, record.payload.issuedAt);
|
|
428
588
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
if (
|
|
436
|
-
|
|
589
|
+
for (const record of theirs.ledger.transitions.slice(fork)) {
|
|
590
|
+
const operation = record.payload.operation;
|
|
591
|
+
if (operation.type !== 'resolve_fork' || operation.forkHead !== mine.heads[fork] || operation.discardedFirst !== mine.heads[fork + 1])
|
|
592
|
+
continue;
|
|
593
|
+
if (seen > 0 && (operation.discardedRevision < seen || mine.heads[operation.discardedRevision] !== operation.discardedHead))
|
|
594
|
+
continue;
|
|
595
|
+
if (newest > record.payload.issuedAt + LEDGER_LIMITS.maxFutureSkewMs)
|
|
596
|
+
continue;
|
|
597
|
+
return true;
|
|
437
598
|
}
|
|
438
|
-
return
|
|
599
|
+
return false;
|
|
439
600
|
}
|
|
440
|
-
/**
|
|
441
|
-
function
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
601
|
+
/** Local's records after the fork, and the revocations among them that the adopted chain lacks. */
|
|
602
|
+
function discardedBy(mine, fork, adopted) {
|
|
603
|
+
const records = mine.ledger.transitions.slice(fork);
|
|
604
|
+
const deviceIds = [];
|
|
605
|
+
const bridgeIds = [];
|
|
606
|
+
for (const record of records) {
|
|
607
|
+
const operation = record.payload.operation;
|
|
608
|
+
if (operation.type === 'revoke_device' && adopted.revokedDeviceIds.indexOf(operation.endpointId) === -1)
|
|
609
|
+
deviceIds.push(operation.endpointId);
|
|
610
|
+
if (operation.type === 'revoke_bridge' && adopted.revokedBridgeIds.indexOf(operation.bridgeId) === -1)
|
|
611
|
+
bridgeIds.push(operation.bridgeId);
|
|
612
|
+
}
|
|
613
|
+
return { records, deviceIds, bridgeIds };
|
|
614
|
+
}
|
|
615
|
+
/** LedgerSeen of a VERIFIED diverged candidate: revocations after `fork` signed by an authority of the shared prefix (joined at or before `fork`). */
|
|
616
|
+
function seenIn(mine, theirs, fork) {
|
|
617
|
+
const authorities = trailOf(mine, fork).joined.map(item => item.credentialId);
|
|
618
|
+
const seen = { deviceIds: [], bridgeIds: [] };
|
|
619
|
+
for (const record of theirs.ledger.transitions.slice(fork)) {
|
|
620
|
+
const { operation, credentialId } = record.payload;
|
|
621
|
+
if (authorities.indexOf(credentialId) === -1)
|
|
622
|
+
continue;
|
|
623
|
+
if (operation.type === 'revoke_device' && seen.deviceIds.indexOf(operation.endpointId) === -1)
|
|
624
|
+
seen.deviceIds.push(operation.endpointId);
|
|
625
|
+
if (operation.type === 'revoke_bridge' && seen.bridgeIds.indexOf(operation.bridgeId) === -1)
|
|
626
|
+
seen.bridgeIds.push(operation.bridgeId);
|
|
627
|
+
}
|
|
628
|
+
return seen;
|
|
448
629
|
}
|
|
449
|
-
function choice(decision, checkpoint, discarded) {
|
|
450
|
-
return {
|
|
630
|
+
function choice(decision, relation, checkpoint, ledger, discarded, seen) {
|
|
631
|
+
return {
|
|
632
|
+
decision, relation, checkpoint: copyOf(checkpoint), discarded: discarded ?? { records: [], deviceIds: [], bridgeIds: [] }, ledger,
|
|
633
|
+
seen: seen ?? { deviceIds: [], bridgeIds: [] },
|
|
634
|
+
};
|
|
451
635
|
}
|
|
452
636
|
/** SHA-256(UTF-8(LEDGER_DOMAIN + canonical(payload))): the WebAuthn challenge, and the record's head. */
|
|
453
637
|
export async function ledgerChallenge(payload) {
|
|
@@ -477,8 +661,8 @@ export function ledgerLastIssuedAt(ledger) {
|
|
|
477
661
|
const last = ledger.transitions[ledger.transitions.length - 1];
|
|
478
662
|
return time(last === undefined ? ledger.genesis.payload.createdAt : last.payload.issuedAt);
|
|
479
663
|
}
|
|
480
|
-
/** Every device endpoint id and bridgeId revoked anywhere in the chain, in chain order. Only for chains that verified
|
|
481
|
-
*
|
|
664
|
+
/** Every device endpoint id and bridgeId revoked anywhere in the chain, in chain order. Only for chains that verified: replicas keep the
|
|
665
|
+
* union over the chains they held (plus chooseLedger's `discarded` ids) and never trust those ids again. Throws on a malformed shape. */
|
|
482
666
|
export function ledgerRevocations(ledger) {
|
|
483
667
|
const deviceIds = [];
|
|
484
668
|
const bridgeIds = [];
|
|
@@ -527,17 +711,61 @@ export async function verifyLedger(ledger, now = Date.now()) {
|
|
|
527
711
|
const at = time(now);
|
|
528
712
|
return copyOf(await guarded(() => verifyAll(parseLedger(ledger), at)));
|
|
529
713
|
}
|
|
530
|
-
/**
|
|
531
|
-
*
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
*
|
|
714
|
+
/** The head of every record, index = revision: [accountId, head of revision 1, ..., head of the tip]. O(n) SHA-256 over the parsed chain and no
|
|
715
|
+
* signature work; compute it once per chain and keep it for relate/forkPoint/relateHead. Throws like parseLedger. */
|
|
716
|
+
export async function ledgerHeads(ledger) {
|
|
717
|
+
return (await guarded(() => indexed(ledger))).heads;
|
|
718
|
+
}
|
|
719
|
+
/** How `candidate` relates to `local` (both ledgerHeads lists of chains of ONE account): 'same' (identical heads), 'ahead' (candidate strictly
|
|
720
|
+
* extends local), 'behind' (local strictly extends candidate), 'diverged' (they differ at some revision both have). Pure, O(min(n, m)) string
|
|
721
|
+
* compares, no hashing. Another account is not a relation: throws ledger_other_account (genesis heads differ); a malformed list throws
|
|
722
|
+
* invalid_ledger. Every head of the shorter list is compared, so a list whose tip matches but whose middle differs is 'diverged'. */
|
|
723
|
+
export function relate(local, candidate) {
|
|
724
|
+
const relation = classify(headList(local), headList(candidate));
|
|
725
|
+
return relation === null ? fail('ledger_other_account') : relation;
|
|
726
|
+
}
|
|
727
|
+
/** The last record `local` and `candidate` (ledgerHeads lists) share, or null when even the genesis differs. Same cost as relate. */
|
|
728
|
+
export function forkPoint(local, candidate) {
|
|
729
|
+
const mine = headList(local);
|
|
730
|
+
const index = firstDifference(mine, headList(candidate));
|
|
731
|
+
return index === 0 ? null : { revision: index - 1, head: mine[index - 1] };
|
|
732
|
+
}
|
|
733
|
+
/** What another replica's bare `{ revision, head }` (an RPC answer or head announcement; other fields ignored) says relative to `local`
|
|
734
|
+
* (a ledgerHeads list): 'same', 'behind' (local holds that head at that revision and more), or 'unknown' (ahead, diverged or another
|
|
735
|
+
* account: fetch its chain and run chooseLedger). O(1). Throws invalid_ledger on a malformed head or revision. */
|
|
736
|
+
export function relateHead(local, other) {
|
|
737
|
+
const mine = headList(local);
|
|
738
|
+
const record = object(other);
|
|
739
|
+
const revision = count(record.revision, 0);
|
|
740
|
+
const head = fingerprint(record.head);
|
|
741
|
+
if (revision >= mine.length || mine[revision] !== head)
|
|
742
|
+
return 'unknown';
|
|
743
|
+
return revision === mine.length - 1 ? 'same' : 'behind';
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* The append-only acceptance rule, the same on every replica (server, browser, bridge). The candidate's relation decides:
|
|
747
|
+
* - no local chain (first bind): verified from genesis and adopted, relation null; an invalid candidate throws.
|
|
748
|
+
* - malformed or another account: 'reject', relation null.
|
|
749
|
+
* - 'same' / 'behind': 'keep', decided from record heads alone (no candidate signature is checked).
|
|
750
|
+
* - 'ahead': only the records after local's head are verified, on top of local's state -> 'adopt', or 'reject' if any is invalid.
|
|
751
|
+
* - 'diverged': the candidate's records after the fork point are verified on top of the shared prefix -> 'reject' if invalid. It is
|
|
752
|
+
* 'adopt'ed only under the owner-signed resolve_fork rule of resolves(); `discarded.records` then lists local's records after the fork.
|
|
753
|
+
* Otherwise 'keep': a valid different history is never adopted, whatever its length or content; `seen` lists its removals to enforce locally.
|
|
754
|
+
* The candidate's copy of the shared prefix is never verified and never stored: its heads equal local's, so its payloads and state do, and
|
|
755
|
+
* only the candidate's records after the fork point (after local's tip when 'ahead') are checked. Store `choice.ledger`, which keeps local's
|
|
756
|
+
* prefix records byte-for-byte; re-encoded prefix signatures therefore cost nothing and are dropped. `cache` is the checkpoint of the
|
|
757
|
+
* already-verified local chain: when it vouches for `local` (same last head, hash-linked) local is not re-verified, else local is verified
|
|
758
|
+
* from genesis. Throws only when `local` is invalid (and not vouched for), or when `local` is null and the candidate is invalid.
|
|
759
|
+
*/
|
|
537
760
|
export async function chooseLedger(local, candidate, now = Date.now(), cache) {
|
|
538
761
|
const at = time(now);
|
|
539
|
-
if (local === null)
|
|
540
|
-
|
|
762
|
+
if (local === null) {
|
|
763
|
+
const first = await guarded(async () => {
|
|
764
|
+
const ledger = parseLedger(candidate);
|
|
765
|
+
return { ledger, checkpoint: await verifyAll(ledger, at) };
|
|
766
|
+
});
|
|
767
|
+
return choice('adopt', null, first.checkpoint, first.ledger);
|
|
768
|
+
}
|
|
541
769
|
const mine = await guarded(() => indexed(local));
|
|
542
770
|
const base = vouched(mine, cache) ?? await guarded(() => verifyAll(mine.ledger, at));
|
|
543
771
|
let theirs;
|
|
@@ -545,38 +773,91 @@ export async function chooseLedger(local, candidate, now = Date.now(), cache) {
|
|
|
545
773
|
theirs = await indexed(candidate);
|
|
546
774
|
}
|
|
547
775
|
catch {
|
|
548
|
-
return choice('reject', base,
|
|
549
|
-
}
|
|
550
|
-
if (theirs.accountId !== mine.accountId)
|
|
551
|
-
return choice('reject', base, []);
|
|
552
|
-
const localLength = mine.heads.length;
|
|
553
|
-
const candidateLength = theirs.heads.length;
|
|
554
|
-
let common = 0;
|
|
555
|
-
while (common < localLength && common < candidateLength && mine.heads[common] === theirs.heads[common])
|
|
556
|
-
common += 1;
|
|
557
|
-
if (common === candidateLength)
|
|
558
|
-
return choice('keep', base, []);
|
|
559
|
-
if (common === localLength && sameRecords(mine.ledger, theirs.ledger, localLength)) {
|
|
560
|
-
try {
|
|
561
|
-
const fresh = theirs.ledger.transitions.slice(localLength);
|
|
562
|
-
return choice('adopt', await extend(base, mine.ledger.genesis.payload, ledgerLastIssuedAt(mine.ledger), fresh, at), []);
|
|
563
|
-
}
|
|
564
|
-
catch {
|
|
565
|
-
return choice('reject', base, []);
|
|
566
|
-
}
|
|
776
|
+
return choice('reject', null, base, mine.ledger);
|
|
567
777
|
}
|
|
568
|
-
|
|
778
|
+
const relation = classify(mine.heads, theirs.heads);
|
|
779
|
+
if (relation === null)
|
|
780
|
+
return choice('reject', null, base, mine.ledger);
|
|
781
|
+
if (relation === 'same' || relation === 'behind')
|
|
782
|
+
return choice('keep', relation, base, mine.ledger);
|
|
783
|
+
// Revision of the last shared record: local's tip when 'ahead'.
|
|
784
|
+
const fork = firstDifference(mine.heads, theirs.heads) - 1;
|
|
785
|
+
// Local is verified or vouched for, so replaying its prefix can only fail on corrupt local state, which throws like an invalid local.
|
|
786
|
+
const start = relation === 'ahead' ? base : await guarded(async () => replay(mine, fork));
|
|
787
|
+
const suffix = theirs.ledger.transitions.slice(fork);
|
|
788
|
+
let verified;
|
|
569
789
|
try {
|
|
570
|
-
|
|
790
|
+
const root = mine.ledger.genesis.payload;
|
|
791
|
+
const previousTime = fork === 0 ? root.createdAt : mine.ledger.transitions[fork - 1].payload.issuedAt;
|
|
792
|
+
verified = await extend(start, root, previousTime, suffix, at, trailOf(mine, fork));
|
|
571
793
|
}
|
|
572
794
|
catch {
|
|
573
|
-
return choice('reject', base,
|
|
795
|
+
return choice('reject', relation, base, mine.ledger);
|
|
574
796
|
}
|
|
575
|
-
|
|
797
|
+
const adopted = { genesis: mine.ledger.genesis, transitions: mine.ledger.transitions.slice(0, fork).concat(suffix) };
|
|
798
|
+
if (relation === 'ahead')
|
|
799
|
+
return choice('adopt', relation, verified, adopted);
|
|
800
|
+
return resolves(mine, theirs, fork, verified)
|
|
801
|
+
? choice('adopt', relation, verified, adopted, discardedBy(mine, fork, verified))
|
|
802
|
+
: choice('keep', relation, base, mine.ledger, undefined, seenIn(mine, theirs, fork));
|
|
803
|
+
}
|
|
804
|
+
/** describeFork(local, other): where the two chains part and each side's payloads after that point, for showing two histories side by side.
|
|
805
|
+
* Works for any relation of one account (for 'same' both lists are empty). Throws ledger_other_account for another account, else like parseLedger.
|
|
806
|
+
* Neither chain is verified: pass only a local chain and one that chooseLedger classified as 'keep' + 'diverged'. */
|
|
807
|
+
export async function describeFork(local, other) {
|
|
808
|
+
const mine = await guarded(() => indexed(local));
|
|
809
|
+
const theirs = await guarded(() => indexed(other));
|
|
810
|
+
const relation = classify(mine.heads, theirs.heads);
|
|
811
|
+
if (relation === null)
|
|
812
|
+
return fail('ledger_other_account');
|
|
813
|
+
const revision = firstDifference(mine.heads, theirs.heads) - 1;
|
|
814
|
+
return {
|
|
815
|
+
relation, fork: { revision, head: mine.heads[revision] },
|
|
816
|
+
mine: mine.ledger.transitions.slice(revision).map(record => record.payload),
|
|
817
|
+
theirs: theirs.ledger.transitions.slice(revision).map(record => record.payload),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
/** What keeping `chosen` over `other` requires first: their fork point, and `carry`, the operations to append to `chosen` (each signed as an
|
|
821
|
+
* ordinary record, in this order) before resolveForkTransition. In `other`'s chain order: one revoke_device / revoke_bridge per id revoked on
|
|
822
|
+
* other's branch after the fork and not revoked in `chosen` (an id that is not a member of `chosen` is tombstoned, so it can never be added
|
|
823
|
+
* there), and one add_authority, with the ORIGINAL authority and consent (a consent binds only accountId, credentialId and key), per authority
|
|
824
|
+
* added on other's branch and absent from `chosen` with the same credentialId and key. Building a carried record can still throw (for example
|
|
825
|
+
* ledger_limit, or invalid_ledger when `chosen` holds that credentialId or key under another authority): then `chosen` cannot be kept.
|
|
826
|
+
* Throws ledger_other_account for another account and invalid_ledger unless the chains diverge. Neither chain is verified: pass only a
|
|
827
|
+
* verified local chain and one that chooseLedger classified as 'keep' + 'diverged'. */
|
|
828
|
+
export async function resolutionPlan(chosen, other) {
|
|
829
|
+
const mine = await guarded(() => indexed(chosen));
|
|
830
|
+
const theirs = await guarded(() => indexed(other));
|
|
831
|
+
const fork = divergence(mine, theirs);
|
|
832
|
+
return { fork: { revision: fork, head: mine.heads[fork] }, carry: missing(theirs.ledger.transitions.slice(fork), holdings(mine.ledger)) };
|
|
833
|
+
}
|
|
834
|
+
/** The unsigned resolve_fork that keeps `chosen` and discards `other` from their fork point: appended to `chosen`, forkHead = the last shared
|
|
835
|
+
* record, discardedFirst = other's first record after it, discardedHead/discardedRevision = other's tip. Verifies `chosen` (skipped when
|
|
836
|
+
* `cache` vouches for it, as in chooseLedger) and throws invalid_ledger when the chains do not diverge or `credentialId` was not an authority
|
|
837
|
+
* at the fork point (one added on chosen's own branch cannot discard the other); resolution_incomplete while resolutionPlan(chosen, other)
|
|
838
|
+
* still has operations to carry; ledger_other_account for another account; else nextTransition's errors. `other` is not verified: pass only
|
|
839
|
+
* a chain that chooseLedger classified as 'keep' + 'diverged'. */
|
|
840
|
+
export async function resolveForkTransition(chosen, other, credentialId, now = Date.now(), cache) {
|
|
841
|
+
const at = time(now);
|
|
842
|
+
const mine = await guarded(() => indexed(chosen));
|
|
843
|
+
const theirs = await guarded(() => indexed(other));
|
|
844
|
+
const fork = divergence(mine, theirs);
|
|
845
|
+
const checkpoint = vouched(mine, cache) ?? await guarded(() => verifyAll(mine.ledger, at));
|
|
846
|
+
const signer = credential(credentialId);
|
|
847
|
+
if (joinedAt(trailOf(mine, fork), signer) === -1)
|
|
848
|
+
fail();
|
|
849
|
+
if (missing(theirs.ledger.transitions.slice(fork), checkpoint).length)
|
|
850
|
+
fail('resolution_incomplete');
|
|
851
|
+
const operation = {
|
|
852
|
+
type: 'resolve_fork', forkHead: mine.heads[fork], discardedFirst: theirs.heads[fork + 1],
|
|
853
|
+
discardedHead: theirs.heads[theirs.heads.length - 1], discardedRevision: theirs.heads.length - 1,
|
|
854
|
+
};
|
|
855
|
+
return nextTransition(checkpoint, signer, operation, at, ledgerLastIssuedAt(mine.ledger));
|
|
576
856
|
}
|
|
577
857
|
/** Pure next-payload builder for the signer. Applies the operation to a copy of the checkpoint with the verifier's rules and throws the
|
|
578
|
-
* same errors. Not checked here (they need async crypto): endpoint id = SHA-256(point), points on the curve,
|
|
579
|
-
* consent signature (only its shape is checked)
|
|
858
|
+
* same errors. Not checked here (they need async crypto or the chain): endpoint id = SHA-256(point), points on the curve, the add_authority
|
|
859
|
+
* consent signature (only its shape is checked) and resolve_fork's forkHead and signer rules (build it with resolveForkTransition);
|
|
860
|
+
* verifyLedger enforces all of them.
|
|
580
861
|
* `lastIssuedAt` is the issuedAt of the ledger's last record (see ledgerLastIssuedAt); the result's issuedAt is max(now, lastIssuedAt). */
|
|
581
862
|
export function nextTransition(checkpoint, credentialId, operation, now = Date.now(), lastIssuedAt) {
|
|
582
863
|
const current = parseCheckpoint(checkpoint);
|