@serfab/cadre-core 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cadre-node.d.ts +101 -61
- package/dist/cadre-node.js +165 -124
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-cohort.d.ts +15 -15
- package/dist/control-cohort.js +14 -14
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +51 -34
- package/dist/control-database.js +72 -56
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +2 -2
- package/dist/control-formation-recorder.js +2 -2
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +57 -33
- package/dist/control-schema.js.map +1 -1
- package/dist/device-token.d.ts +1 -1
- package/dist/device-token.js +1 -1
- package/dist/{authority-key.d.ts → ed25519-key.d.ts} +11 -10
- package/dist/{authority-key.js → ed25519-key.js} +9 -10
- package/dist/ed25519-key.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/key-store.d.ts +1 -1
- package/dist/key-store.js +1 -1
- package/dist/peer-authorization.d.ts +28 -6
- package/dist/peer-authorization.js +33 -7
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-record.d.ts +3 -3
- package/dist/peer-record.js +3 -3
- package/dist/push-node.d.ts +38 -0
- package/dist/push-node.js +48 -0
- package/dist/push-node.js.map +1 -0
- package/dist/push-notifier-apns.d.ts +10 -2
- package/dist/push-notifier-apns.js +14 -10
- package/dist/push-notifier-apns.js.map +1 -1
- package/dist/push-notifier-fcm.d.ts +7 -1
- package/dist/push-notifier-fcm.js +9 -10
- package/dist/push-notifier-fcm.js.map +1 -1
- package/dist/push-notifier.d.ts +16 -35
- package/dist/push-notifier.js +16 -44
- package/dist/push-notifier.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +70 -51
- package/dist/seed-bootstrap.js +168 -106
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +11 -11
- package/dist/seed-trust-policy.js +11 -11
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-database.d.ts +5 -5
- package/dist/strand-database.js +4 -4
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +1 -1
- package/dist/strand-formation-protocol.js +1 -1
- package/dist/strand-instance-manager.d.ts +1 -1
- package/dist/strand-instance-manager.js +1 -1
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-key.d.ts +6 -6
- package/dist/strand-member-key.js +6 -6
- package/dist/strand-member-key.js.map +1 -1
- package/dist/strand-member-registry.d.ts +7 -7
- package/dist/strand-member-registry.js +5 -5
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +84 -84
- package/dist/strand-membership-writer.js +90 -90
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/types.d.ts +52 -32
- package/dist/types.js.map +1 -1
- package/package.json +7 -3
- package/src/cadre-node.ts +170 -126
- package/src/control-cohort.ts +17 -17
- package/src/control-database.ts +76 -59
- package/src/control-formation-recorder.ts +2 -2
- package/src/control-schema.ts +57 -33
- package/src/device-token.ts +1 -1
- package/src/{authority-key.ts → ed25519-key.ts} +12 -11
- package/src/index.ts +21 -19
- package/src/key-store.ts +1 -1
- package/src/peer-authorization.ts +35 -7
- package/src/peer-record.ts +3 -3
- package/src/push-node.ts +64 -0
- package/src/push-notifier-apns.ts +16 -12
- package/src/push-notifier-fcm.ts +9 -10
- package/src/push-notifier.ts +16 -57
- package/src/seed-bootstrap.ts +179 -115
- package/src/seed-trust-policy.ts +18 -18
- package/src/strand-database.ts +6 -6
- package/src/strand-formation-protocol.ts +1 -1
- package/src/strand-instance-manager.ts +2 -2
- package/src/strand-member-key.ts +7 -7
- package/src/strand-member-registry.ts +10 -10
- package/src/strand-membership-writer.ts +127 -127
- package/src/types.ts +52 -32
- package/dist/authority-key.js.map +0 -1
package/src/control-cohort.ts
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
* connected (and thus replicating), without devolving into an N² mesh.
|
|
6
6
|
*
|
|
7
7
|
* FRET only needs a *connected* graph with the block clusters reachable, not a
|
|
8
|
-
* full mesh. Storage/
|
|
8
|
+
* full mesh. Storage/owner nodes are the stable, publicly-dialable backbone
|
|
9
9
|
* that hold the control blocks, so the policy is **backbone-preferential with a
|
|
10
10
|
* bounded out-degree**:
|
|
11
11
|
*
|
|
12
|
-
* - **Always dial every backbone (
|
|
12
|
+
* - **Always dial every backbone (owner) member** — the owner set is
|
|
13
13
|
* small by design, and routing cohort formation through publicly-dialable
|
|
14
|
-
*
|
|
15
|
-
* - **Fill the remainder** up to `targetDegree` with non-
|
|
14
|
+
* owner nodes keeps the cohort ≥2 where owner-signed writes originate.
|
|
15
|
+
* - **Fill the remainder** up to `targetDegree` with non-owner members,
|
|
16
16
|
* ordered deterministically (peerId sort) so every node makes the same stable
|
|
17
17
|
* choice across passes and load spreads predictably.
|
|
18
18
|
*
|
|
@@ -32,8 +32,8 @@ import type { CohortPeerRow } from './strand-cohort.js';
|
|
|
32
32
|
export const DEFAULT_CONTROL_COHORT_RECONCILE_MS = 15_000;
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* Default cap on the number of **non-
|
|
36
|
-
* proactively dials. Backbone (
|
|
35
|
+
* Default cap on the number of **non-owner** members a single reconcile pass
|
|
36
|
+
* proactively dials. Backbone (owner) members are always dialed and do NOT
|
|
37
37
|
* count against this cap.
|
|
38
38
|
*/
|
|
39
39
|
export const DEFAULT_CONTROL_COHORT_TARGET_DEGREE = 6;
|
|
@@ -41,14 +41,14 @@ export const DEFAULT_CONTROL_COHORT_TARGET_DEGREE = 6;
|
|
|
41
41
|
/** Outcome of {@link selectControlCohortDials}: who to dial + what the cap dropped. */
|
|
42
42
|
export interface ControlCohortSelection {
|
|
43
43
|
/**
|
|
44
|
-
* Siblings to dial this pass, backbone first then the bounded non-
|
|
44
|
+
* Siblings to dial this pass, backbone first then the bounded non-owner
|
|
45
45
|
* fill. Already self-excluded (the caller must pass self-excluded `siblings`).
|
|
46
46
|
* Still includes peers that may turn out to be already-connected — the caller
|
|
47
47
|
* diffs against live connections separately.
|
|
48
48
|
*/
|
|
49
49
|
dials: CohortPeerRow[];
|
|
50
|
-
/** Non-
|
|
51
|
-
|
|
50
|
+
/** Non-owner members dropped by the `targetDegree` cap (0 when none). */
|
|
51
|
+
cappedNonOwner: number;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** Stable peerId ordering so the bounded fill is identical across passes/nodes. */
|
|
@@ -63,19 +63,19 @@ function byPeerId(a: CohortPeerRow, b: CohortPeerRow): number {
|
|
|
63
63
|
* to dial.
|
|
64
64
|
*
|
|
65
65
|
* A member is **backbone** iff the ed25519 key derived from its peerId
|
|
66
|
-
* (see {@link ed25519PublicKeyB64FromPeerId}) is in `
|
|
67
|
-
* members are selected; non-
|
|
66
|
+
* (see {@link ed25519PublicKeyB64FromPeerId}) is in `ownerKeys`. All backbone
|
|
67
|
+
* members are selected; non-owner members fill up to `targetDegree` in
|
|
68
68
|
* deterministic peerId order.
|
|
69
69
|
*
|
|
70
70
|
* @param siblings - cadre members EXCLUDING self (the caller filters self out).
|
|
71
|
-
* @param
|
|
72
|
-
* before
|
|
71
|
+
* @param ownerKeys - the converged `OwnerKey` set; may be empty/partial
|
|
72
|
+
* before owner convergence, in which case no member classifies as backbone
|
|
73
73
|
* yet and the bounded fill still makes progress (preference sharpens later).
|
|
74
|
-
* @param targetDegree - cap on non-
|
|
74
|
+
* @param targetDegree - cap on non-owner dials (negative is treated as 0).
|
|
75
75
|
*/
|
|
76
76
|
export function selectControlCohortDials(
|
|
77
77
|
siblings: CohortPeerRow[],
|
|
78
|
-
|
|
78
|
+
ownerKeys: ReadonlySet<string>,
|
|
79
79
|
targetDegree: number,
|
|
80
80
|
): ControlCohortSelection {
|
|
81
81
|
const backbone: CohortPeerRow[] = [];
|
|
@@ -83,7 +83,7 @@ export function selectControlCohortDials(
|
|
|
83
83
|
|
|
84
84
|
for (const peer of siblings) {
|
|
85
85
|
const pubKey = ed25519PublicKeyB64FromPeerId(peer.peerId);
|
|
86
|
-
if (pubKey !== null &&
|
|
86
|
+
if (pubKey !== null && ownerKeys.has(pubKey)) {
|
|
87
87
|
backbone.push(peer);
|
|
88
88
|
} else {
|
|
89
89
|
rest.push(peer);
|
|
@@ -98,6 +98,6 @@ export function selectControlCohortDials(
|
|
|
98
98
|
|
|
99
99
|
return {
|
|
100
100
|
dials: [...backbone, ...fill],
|
|
101
|
-
|
|
101
|
+
cappedNonOwner: rest.length - fill.length,
|
|
102
102
|
};
|
|
103
103
|
}
|
package/src/control-database.ts
CHANGED
|
@@ -20,7 +20,7 @@ const timing = debug('sereus:cadre:timing');
|
|
|
20
20
|
* - First 16 bytes: SHA-256 hash of peer ID (for distributed uniqueness)
|
|
21
21
|
* - Last 16 bytes: Random bytes (for collision resistance)
|
|
22
22
|
*/
|
|
23
|
-
function generateStampId(peerId: string): string {
|
|
23
|
+
export function generateStampId(peerId: string): string {
|
|
24
24
|
// Hash the peer ID and get first 16 bytes (128 bits). A purely local ID
|
|
25
25
|
// generator: never signed/verified against SQL, so the framed single-field
|
|
26
26
|
// digest is fine (the changed framing is not cross-checked anywhere).
|
|
@@ -54,7 +54,7 @@ function parseStoredDatetimeMs(value: string | number): number {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* Build the canonical authorization message that
|
|
57
|
+
* Build the canonical authorization message that owner signatures are bound to.
|
|
58
58
|
*
|
|
59
59
|
* The message is a SINGLE framed SHA-256 digest over the ordered tuple of fields (the
|
|
60
60
|
* crypto plugin's injective multi-field encoding), in a fixed field order, with the
|
|
@@ -111,7 +111,7 @@ interface OptimysticPluginResult {
|
|
|
111
111
|
* `waitForControlSync`) a typo-proof table argument.
|
|
112
112
|
*/
|
|
113
113
|
export type ControlTable =
|
|
114
|
-
| '
|
|
114
|
+
| 'OwnerKey'
|
|
115
115
|
| 'ValidationKey'
|
|
116
116
|
| 'Strand'
|
|
117
117
|
| 'CadrePeer'
|
|
@@ -121,7 +121,7 @@ export type ControlTable =
|
|
|
121
121
|
|
|
122
122
|
/** Runtime guard mirroring {@link ControlTable} for the dynamic-`from` count. */
|
|
123
123
|
const CONTROL_TABLES: ReadonlySet<ControlTable> = new Set<ControlTable>([
|
|
124
|
-
'
|
|
124
|
+
'OwnerKey',
|
|
125
125
|
'ValidationKey',
|
|
126
126
|
'Strand',
|
|
127
127
|
'CadrePeer',
|
|
@@ -329,7 +329,7 @@ export class ControlDatabase {
|
|
|
329
329
|
* into the `from` clause: the names are not user input, but the check keeps the
|
|
330
330
|
* dynamic query off the injection surface and fails loudly on a typo instead of
|
|
331
331
|
* emitting a malformed statement. The count reflects only the rows this node's
|
|
332
|
-
* control DB has converged on — in the integration harness that is the
|
|
332
|
+
* control DB has converged on — in the integration harness that is the owner
|
|
333
333
|
* node (one ControlDatabase per party), i.e. the authoritative control-network
|
|
334
334
|
* view, not a per-drone convergence guarantee.
|
|
335
335
|
*/
|
|
@@ -353,44 +353,44 @@ export class ControlDatabase {
|
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
/**
|
|
356
|
-
* Check whether any
|
|
356
|
+
* Check whether any owner key exists in the control database.
|
|
357
357
|
* Used to decide whether a fresh-party genesis insert is required.
|
|
358
358
|
*/
|
|
359
|
-
async
|
|
359
|
+
async hasOwnerKey(): Promise<boolean> {
|
|
360
360
|
this.ensureInitialized();
|
|
361
|
-
for await (const row of this.db!.eval('select count(1) as Count from CadreControl.
|
|
361
|
+
for await (const row of this.db!.eval('select count(1) as Count from CadreControl.OwnerKey')) {
|
|
362
362
|
return (row.Count as number) > 0;
|
|
363
363
|
}
|
|
364
364
|
return false;
|
|
365
365
|
}
|
|
366
366
|
|
|
367
367
|
/**
|
|
368
|
-
* Idempotent genesis: insert `key` as the founding
|
|
369
|
-
* the party has none yet. Returns true if it inserted, false if an
|
|
370
|
-
* key already existed (so a repeat `--
|
|
368
|
+
* Idempotent genesis: insert `key` as the founding owner key only when
|
|
369
|
+
* the party has none yet. Returns true if it inserted, false if an owner
|
|
370
|
+
* key already existed (so a repeat `--owner` start is a no-op).
|
|
371
371
|
*/
|
|
372
|
-
async
|
|
372
|
+
async ensureOwnerKey(key: string): Promise<boolean> {
|
|
373
373
|
this.ensureInitialized();
|
|
374
|
-
if (await this.
|
|
375
|
-
log('
|
|
374
|
+
if (await this.hasOwnerKey()) {
|
|
375
|
+
log('Owner key already present; skipping genesis insert');
|
|
376
376
|
return false;
|
|
377
377
|
}
|
|
378
|
-
await this.
|
|
378
|
+
await this.insertOwnerKey(key);
|
|
379
379
|
return true;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
382
|
/**
|
|
383
|
-
* Collect every
|
|
383
|
+
* Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
|
|
384
384
|
*
|
|
385
385
|
* This is the steady-state trust anchor for seeds: a seed's signer key is
|
|
386
386
|
* trusted only if it is already enrolled here (see `SeedTrustPolicy`). It is
|
|
387
|
-
* also the
|
|
387
|
+
* also the owner-identity source for `queryPeers`, decoupling owner
|
|
388
388
|
* status from the libp2p transport peer ID.
|
|
389
389
|
*/
|
|
390
|
-
async
|
|
390
|
+
async getOwnerKeys(): Promise<Set<string>> {
|
|
391
391
|
this.ensureInitialized();
|
|
392
392
|
const keys = new Set<string>();
|
|
393
|
-
for await (const row of this.db!.eval('select Key from CadreControl.
|
|
393
|
+
for await (const row of this.db!.eval('select Key from CadreControl.OwnerKey')) {
|
|
394
394
|
keys.add(row.Key as string);
|
|
395
395
|
}
|
|
396
396
|
return keys;
|
|
@@ -399,18 +399,35 @@ export class ControlDatabase {
|
|
|
399
399
|
/**
|
|
400
400
|
* Enumerate the CadrePeer rows (cadre membership) for admin/membership reads.
|
|
401
401
|
*/
|
|
402
|
-
async queryCadrePeers(): Promise<Array<{ peerId: string; multiaddr: string | null }>> {
|
|
402
|
+
async queryCadrePeers(): Promise<Array<{ peerId: string; multiaddr: string | null; stampId: string | null; vouchOwner: string | null; vouchSig: string | null }>> {
|
|
403
403
|
this.ensureInitialized();
|
|
404
|
-
const rows: Array<{ peerId: string; multiaddr: string | null }> = [];
|
|
405
|
-
for await (const row of this.db!.eval('select PeerId, Multiaddr from CadreControl.CadrePeer')) {
|
|
404
|
+
const rows: Array<{ peerId: string; multiaddr: string | null; stampId: string | null; vouchOwner: string | null; vouchSig: string | null }> = [];
|
|
405
|
+
for await (const row of this.db!.eval('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer')) {
|
|
406
406
|
rows.push({
|
|
407
407
|
peerId: row.PeerId as string,
|
|
408
408
|
multiaddr: (row.Multiaddr as string | null) ?? null,
|
|
409
|
+
stampId: (row.StampId as string | null) ?? null,
|
|
410
|
+
vouchOwner: (row.VouchOwner as string | null) ?? null,
|
|
411
|
+
vouchSig: (row.VouchSig as string | null) ?? null,
|
|
409
412
|
});
|
|
410
413
|
}
|
|
411
414
|
return rows;
|
|
412
415
|
}
|
|
413
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Read a single `CadrePeer` row's single-use `StampId` nonce (null when the row
|
|
419
|
+
* does not exist). Used by the owner delete / re-touch paths, which must bind
|
|
420
|
+
* their signature to the row's CURRENT nonce ({@link cadrePeerRemoveDigest} /
|
|
421
|
+
* {@link cadrePeerVoucherDigest}).
|
|
422
|
+
*/
|
|
423
|
+
async queryCadrePeerStampId(peerId: string): Promise<string | null> {
|
|
424
|
+
this.ensureInitialized();
|
|
425
|
+
for await (const row of this.db!.eval('select StampId from CadreControl.CadrePeer where PeerId = ?', [peerId])) {
|
|
426
|
+
return (row.StampId as string | null) ?? null;
|
|
427
|
+
}
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
|
|
414
431
|
/**
|
|
415
432
|
* Read a single peer's address record (the full `CadrePeer` row) by PeerId.
|
|
416
433
|
*
|
|
@@ -444,8 +461,8 @@ export class ControlDatabase {
|
|
|
444
461
|
*
|
|
445
462
|
* Authorization is carried entirely by the record: the `Sig` column (verified
|
|
446
463
|
* by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
|
|
447
|
-
* the strictly-increasing `UpdatedAt`. No
|
|
448
|
-
* is the refresh path for any member —
|
|
464
|
+
* the strictly-increasing `UpdatedAt`. No owner key is involved, so this
|
|
465
|
+
* is the refresh path for any member — owner or drone — once its row
|
|
449
466
|
* exists. `PublicKey` is intentionally not in the SET list (it is immutable on
|
|
450
467
|
* self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
|
|
451
468
|
*/
|
|
@@ -454,7 +471,7 @@ export class ControlDatabase {
|
|
|
454
471
|
const multiaddr = record.addrs.join(',');
|
|
455
472
|
await this.db!.exec(`
|
|
456
473
|
update CadreControl.CadrePeer
|
|
457
|
-
with context
|
|
474
|
+
with context OwnerKey = null, Signature = ?
|
|
458
475
|
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
459
476
|
where PeerId = ?
|
|
460
477
|
`, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId]);
|
|
@@ -492,7 +509,7 @@ export class ControlDatabase {
|
|
|
492
509
|
*
|
|
493
510
|
* Authorization is carried entirely by the record: the `Sig` column (verified by
|
|
494
511
|
* the `DeviceToken.AuthorizedUpdate` self-branch against the stored
|
|
495
|
-
* `CadrePeer.PublicKey`) plus the strictly-increasing `UpdatedAt`. No
|
|
512
|
+
* `CadrePeer.PublicKey`) plus the strictly-increasing `UpdatedAt`. No owner key
|
|
496
513
|
* is involved, so this is the refresh / rotation path for any member once both its
|
|
497
514
|
* `CadrePeer` row (for the PublicKey) and its `DeviceToken` row exist. `PeerId` is
|
|
498
515
|
* intentionally not in the SET list (immutable; the constraint enforces
|
|
@@ -502,7 +519,7 @@ export class ControlDatabase {
|
|
|
502
519
|
this.ensureInitialized();
|
|
503
520
|
await this.db!.exec(`
|
|
504
521
|
update CadreControl.DeviceToken
|
|
505
|
-
with context
|
|
522
|
+
with context OwnerKey = null, Signature = ?
|
|
506
523
|
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
507
524
|
where PeerId = ?
|
|
508
525
|
`, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId]);
|
|
@@ -510,44 +527,44 @@ export class ControlDatabase {
|
|
|
510
527
|
}
|
|
511
528
|
|
|
512
529
|
/**
|
|
513
|
-
* Insert the initial
|
|
530
|
+
* Insert the initial owner key (bootstrap - no existing owners required)
|
|
514
531
|
*/
|
|
515
|
-
async
|
|
532
|
+
async insertOwnerKey(key: string): Promise<void> {
|
|
516
533
|
this.ensureInitialized();
|
|
517
|
-
log('Inserting
|
|
534
|
+
log('Inserting owner key: %s', key);
|
|
518
535
|
|
|
519
|
-
// Bootstrap is authorized by the schema's `(select count(1) from
|
|
536
|
+
// Bootstrap is authorized by the schema's `(select count(1) from OwnerKey) <= 1`
|
|
520
537
|
// branch, so no signature is needed. We still persist a fresh, unique StampId in the
|
|
521
538
|
// row's own column to satisfy the not-null/unique anti-replay constraint — the StampId
|
|
522
539
|
// is now a real column value, not the optimystic `StampId()` SQL function.
|
|
523
540
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
524
541
|
await this.db!.exec(`
|
|
525
|
-
insert into CadreControl.
|
|
526
|
-
with context
|
|
542
|
+
insert into CadreControl.OwnerKey (Key, StampId)
|
|
543
|
+
with context OwnerKey = null, Signature = null
|
|
527
544
|
values (?, ?)
|
|
528
545
|
`, [key, stampId]);
|
|
529
|
-
log('
|
|
546
|
+
log('Owner key inserted');
|
|
530
547
|
}
|
|
531
548
|
|
|
532
549
|
/**
|
|
533
|
-
* Insert a strand into the control database using an
|
|
550
|
+
* Insert a strand into the control database using an owner signature.
|
|
534
551
|
*
|
|
535
|
-
* The
|
|
552
|
+
* The owner signs the canonical row-bound authorization message (see
|
|
536
553
|
* {@link buildAuthorizationMessage}) — NOT a bare stamp — so the signature is bound to
|
|
537
554
|
* this strand's contents and cannot be transplanted onto an attacker-chosen row. The
|
|
538
555
|
* StampId is persisted as a unique column for single-use anti-replay.
|
|
539
556
|
*
|
|
540
557
|
* @param strandId - Unique identifier for the strand
|
|
541
558
|
* @param type - Strand type: 'o' for open, 'c' for closed
|
|
542
|
-
* @param
|
|
559
|
+
* @param ownerKey - Public key of the authorizing owner
|
|
543
560
|
* @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
|
|
544
|
-
* with the
|
|
561
|
+
* with the owner's private key, returning a base64url signature
|
|
545
562
|
* @param memberPrivateKey - Optional private key for membership in closed strands
|
|
546
563
|
*/
|
|
547
564
|
async insertStrand(
|
|
548
565
|
strandId: string,
|
|
549
566
|
type: 'o' | 'c',
|
|
550
|
-
|
|
567
|
+
ownerKey: string,
|
|
551
568
|
signMessage: (message: Uint8Array) => string,
|
|
552
569
|
memberPrivateKey?: string
|
|
553
570
|
): Promise<void> {
|
|
@@ -566,29 +583,29 @@ export class ControlDatabase {
|
|
|
566
583
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
567
584
|
await this.db!.exec(`
|
|
568
585
|
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
569
|
-
with context
|
|
586
|
+
with context OwnerKey = ?, Signature = ?
|
|
570
587
|
values (?, ?, ?, ?)
|
|
571
|
-
`, [
|
|
588
|
+
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId]);
|
|
572
589
|
|
|
573
590
|
log('Strand inserted: %s', strandId);
|
|
574
591
|
}
|
|
575
592
|
|
|
576
593
|
/**
|
|
577
|
-
* Insert a validation key into the control database using an
|
|
594
|
+
* Insert a validation key into the control database using an owner signature.
|
|
578
595
|
*
|
|
579
|
-
* Mirrors {@link insertStrand}: the
|
|
596
|
+
* Mirrors {@link insertStrand}: the owner signs the canonical row-bound
|
|
580
597
|
* authorization message over (Key, StampId), and the StampId is persisted as a unique
|
|
581
598
|
* column for single-use anti-replay. A `ValidationKey` authorizes verifying strand
|
|
582
599
|
* formation disclosures.
|
|
583
600
|
*
|
|
584
601
|
* @param key - The validation public key to enroll
|
|
585
|
-
* @param
|
|
602
|
+
* @param ownerKey - Public key of the authorizing owner
|
|
586
603
|
* @param signMessage - Function that ed25519-signs the raw message bytes (no pre-hash)
|
|
587
|
-
* with the
|
|
604
|
+
* with the owner's private key, returning a base64url signature
|
|
588
605
|
*/
|
|
589
606
|
async insertValidationKey(
|
|
590
607
|
key: string,
|
|
591
|
-
|
|
608
|
+
ownerKey: string,
|
|
592
609
|
signMessage: (message: Uint8Array) => string
|
|
593
610
|
): Promise<void> {
|
|
594
611
|
this.ensureInitialized();
|
|
@@ -603,22 +620,22 @@ export class ControlDatabase {
|
|
|
603
620
|
|
|
604
621
|
await this.db!.exec(`
|
|
605
622
|
insert into CadreControl.ValidationKey (Key, StampId)
|
|
606
|
-
with context
|
|
623
|
+
with context OwnerKey = ?, Signature = ?
|
|
607
624
|
values (?, ?)
|
|
608
|
-
`, [
|
|
625
|
+
`, [ownerKey, signature, key, stampId]);
|
|
609
626
|
|
|
610
627
|
log('Validation key inserted: %s', key);
|
|
611
628
|
}
|
|
612
629
|
|
|
613
630
|
/**
|
|
614
|
-
* Insert an
|
|
631
|
+
* Insert an owner-signed `FormationInvite` (open invitation token).
|
|
615
632
|
*
|
|
616
633
|
* The invite is the on-network record that later authorizes an
|
|
617
|
-
*
|
|
634
|
+
* owner-signature-FREE `Strand` creation: an invited cadre peer redeems it
|
|
618
635
|
* by inserting a matching `FormationUsage` row (see {@link redeemInvitation}),
|
|
619
636
|
* which satisfies the consent branch of `Strand.Authorized`.
|
|
620
637
|
*
|
|
621
|
-
* Like {@link insertStrand}/{@link insertValidationKey}, the
|
|
638
|
+
* Like {@link insertStrand}/{@link insertValidationKey}, the owner signs the
|
|
622
639
|
* canonical row-bound authorization message (see {@link buildAuthorizationMessage})
|
|
623
640
|
* over (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId) — NOT a
|
|
624
641
|
* bare stamp — so the signature is bound to this invite's contents and cannot be
|
|
@@ -643,7 +660,7 @@ export class ControlDatabase {
|
|
|
643
660
|
*
|
|
644
661
|
* @param token - Invitation token (the `FormationInvite` primary key)
|
|
645
662
|
* @param sAppId - The sApp a redeemed strand will use
|
|
646
|
-
* @param
|
|
663
|
+
* @param ownerKey - Public key of the authorizing owner
|
|
647
664
|
* @param signMessage - ed25519-signs the raw message bytes (no pre-hash),
|
|
648
665
|
* returning a base64url signature — the same callback shape {@link insertStrand} uses
|
|
649
666
|
* @param options - Optional `expiresAtMs` (epoch ms), `totalUses`, `validationUrl`,
|
|
@@ -652,7 +669,7 @@ export class ControlDatabase {
|
|
|
652
669
|
async insertFormationInvite(
|
|
653
670
|
token: string,
|
|
654
671
|
sAppId: string,
|
|
655
|
-
|
|
672
|
+
ownerKey: string,
|
|
656
673
|
signMessage: (message: Uint8Array) => string,
|
|
657
674
|
options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string } = {}
|
|
658
675
|
): Promise<void> {
|
|
@@ -687,10 +704,10 @@ export class ControlDatabase {
|
|
|
687
704
|
// unique column (single-use anti-replay), no longer a context value.
|
|
688
705
|
await this.db!.exec(`
|
|
689
706
|
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
690
|
-
with context
|
|
707
|
+
with context OwnerKey = ?, Signature = ?
|
|
691
708
|
values (?, ?, ?, ?, ?, ?, ?)
|
|
692
709
|
`, [
|
|
693
|
-
|
|
710
|
+
ownerKey, signature,
|
|
694
711
|
token, sAppId,
|
|
695
712
|
expiresAtCanonical,
|
|
696
713
|
options.totalUses ?? null,
|
|
@@ -711,7 +728,7 @@ export class ControlDatabase {
|
|
|
711
728
|
* `FormationUsage.StrandExists` requires the `Strand` row. Both CHECKs contain
|
|
712
729
|
* subqueries, so Quereus auto-defers them to transaction commit — wrapping both
|
|
713
730
|
* inserts in a single explicit `begin … commit` lets both deferred CHECKs see
|
|
714
|
-
* both rows at commit. The strand is authorised WITHOUT an
|
|
731
|
+
* both rows at commit. The strand is authorised WITHOUT an owner signature
|
|
715
732
|
* (the `FormationUsage` branch of `Strand.Authorized`) but still gets a fresh,
|
|
716
733
|
* unique `StampId` column to satisfy the not-null/unique anti-replay column.
|
|
717
734
|
*
|
|
@@ -746,11 +763,11 @@ export class ControlDatabase {
|
|
|
746
763
|
|
|
747
764
|
await this.db!.beginTransaction();
|
|
748
765
|
try {
|
|
749
|
-
// 1. Strand row — authorised by the FormationUsage branch (no
|
|
766
|
+
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
750
767
|
// still carrying a fresh unique StampId for the anti-replay column.
|
|
751
768
|
await this.db!.exec(`
|
|
752
769
|
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
753
|
-
with context
|
|
770
|
+
with context OwnerKey = null, Signature = null
|
|
754
771
|
values (?, ?, ?, ?)
|
|
755
772
|
`, [strandId, type, memberPrivateKey ?? null, strandStampId]);
|
|
756
773
|
|
|
@@ -778,7 +795,7 @@ export class ControlDatabase {
|
|
|
778
795
|
/**
|
|
779
796
|
* Record a `FormationUsage` against an **already-existing** `Strand` (no strand
|
|
780
797
|
* insert). This is the redemption path when the strand was provisioned
|
|
781
|
-
* separately (e.g.
|
|
798
|
+
* separately (e.g. owner-signed) and the consent record is added after the
|
|
782
799
|
* fact: the single insert auto-commits, and the deferred `StrandExists` CHECK
|
|
783
800
|
* is satisfied by the pre-existing committed strand row. Returns the assigned
|
|
784
801
|
* `UseNumber`.
|
|
@@ -16,7 +16,7 @@ const NEVER_EXPIRES = new Date(8640000000000000);
|
|
|
16
16
|
* usage questions, and writes the consent row that records a redemption. Two
|
|
17
17
|
* provisioning shapes are supported, keyed on whether the invite binds a host strand:
|
|
18
18
|
*
|
|
19
|
-
* - **Bound (provision-then-record):** the host strand already exists (
|
|
19
|
+
* - **Bound (provision-then-record):** the host strand already exists (owner-signed
|
|
20
20
|
* up front and named by the invite's `StrandId`), so {@link resolveStrand} reports it
|
|
21
21
|
* and {@link recordUsage} writes the consent row against that pre-existing strand
|
|
22
22
|
* (record-only) rather than inserting a new `Strand`.
|
|
@@ -74,7 +74,7 @@ export class ControlFormationUsageRecorder implements FormationUsageRecorder {
|
|
|
74
74
|
* Record consent against an **already-existing** host strand (record-only): the
|
|
75
75
|
* single `FormationUsage` insert auto-commits and the deferred `StrandExists`
|
|
76
76
|
* CHECK is satisfied by the pre-existing strand. This is the provision-then-record
|
|
77
|
-
* commitment — the strand was minted
|
|
77
|
+
* commitment — the strand was minted owner-signed up front, so we do NOT
|
|
78
78
|
* re-insert it (which would double-insert the same PK). `initiatorKey` is carried
|
|
79
79
|
* as the usage `PeerId` (advisory). Use {@link ControlDatabase.redeemInvitation}
|
|
80
80
|
* for the consent-creates-strand path instead.
|
package/src/control-schema.ts
CHANGED
|
@@ -12,30 +12,30 @@
|
|
|
12
12
|
export const CONTROL_SCHEMA = `-- This manages a Sereus party's cadre, or set of nodes, and their participation in strands (networks)
|
|
13
13
|
declare schema CadreControl {
|
|
14
14
|
-- A key that can authorize various control changes
|
|
15
|
-
table
|
|
15
|
+
table OwnerKey (
|
|
16
16
|
Key text primary key,
|
|
17
17
|
StampId text not null unique, -- single-use authorization nonce (anti-replay)
|
|
18
18
|
constraint Authorized check (
|
|
19
|
-
-- Bootstrap: first
|
|
20
|
-
(select count(1) from
|
|
19
|
+
-- Bootstrap: first owner key needs no existing authorization
|
|
20
|
+
(select count(1) from OwnerKey) <= 1
|
|
21
21
|
|
|
22
|
-
-- Old
|
|
23
|
-
or (old.Key is not null and old.Key = context.
|
|
22
|
+
-- Old owner authorizes by signing over THIS row (Key, StampId); single-use via unique StampId
|
|
23
|
+
or (old.Key is not null and old.Key = context.OwnerKey and verify(digest(new.Key, new.StampId), context.Signature, old.Key, 'ed25519'))
|
|
24
24
|
|
|
25
|
-
-- or other
|
|
26
|
-
or exists (select 1 from
|
|
25
|
+
-- or other owners authorize by signing over THIS row (Key, StampId); single-use via unique StampId
|
|
26
|
+
or exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.Key, new.StampId), context.Signature, A.Key, 'ed25519'))
|
|
27
27
|
)
|
|
28
|
-
) with context (
|
|
28
|
+
) with context (OwnerKey text null, Signature text null);
|
|
29
29
|
|
|
30
30
|
-- A key that can validate a strand formation disclosure
|
|
31
31
|
table ValidationKey (
|
|
32
32
|
Key text primary key,
|
|
33
33
|
StampId text not null unique, -- single-use authorization nonce (anti-replay)
|
|
34
34
|
constraint Authorized check (
|
|
35
|
-
--
|
|
36
|
-
exists (select 1 from
|
|
35
|
+
-- Owners authorize by signing over THIS row (Key, StampId); single-use via unique StampId
|
|
36
|
+
exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.Key, new.StampId), context.Signature, A.Key, 'ed25519'))
|
|
37
37
|
)
|
|
38
|
-
) with context (
|
|
38
|
+
) with context (OwnerKey text, Signature text);
|
|
39
39
|
|
|
40
40
|
-- A network of members sharing an sApp database, each contributing peer nodes (their cadre) to the overall cohort
|
|
41
41
|
-- Cadre peers should participate in each of these strands
|
|
@@ -45,8 +45,8 @@ declare schema CadreControl {
|
|
|
45
45
|
Type text, -- Types: 'o' = Open, 'c' = Closed -- Open can still control writes in the sApp, but only Closed controls reads
|
|
46
46
|
StampId text not null unique, -- single-use authorization nonce (anti-replay)
|
|
47
47
|
constraint Authorized check (
|
|
48
|
-
-- Authorized by an
|
|
49
|
-
exists (select 1 from
|
|
48
|
+
-- Authorized by an owner signing over THIS row (Id, Type, MemberPrivateKey, StampId); single-use via unique StampId
|
|
49
|
+
exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.Id, new.Type, coalesce(new.MemberPrivateKey, ''), new.StampId), context.Signature, A.Key, 'ed25519'))
|
|
50
50
|
|
|
51
51
|
-- or authorized by a cadre peer who has received a valid formation invitation.
|
|
52
52
|
-- That path has no signed writer yet; when one is built it must still supply a
|
|
@@ -58,7 +58,7 @@ declare schema CadreControl {
|
|
|
58
58
|
-- member key. A non-null MemberPrivateKey requires a closed strand ('c').
|
|
59
59
|
new.MemberPrivateKey is null or new.Type = 'c'
|
|
60
60
|
)
|
|
61
|
-
) with context (
|
|
61
|
+
) with context (OwnerKey text, Signature text);
|
|
62
62
|
|
|
63
63
|
-- A peer (node) that is part of the cadre, carrying a self-published,
|
|
64
64
|
-- freshness-stamped, self-signed address record (see PeerAddressRecord in cadre-core).
|
|
@@ -70,31 +70,55 @@ declare schema CadreControl {
|
|
|
70
70
|
Multiaddr text, -- comma-joined current addrs (signaling / p2p-circuit first)
|
|
71
71
|
UpdatedAt int null, -- epoch ms; strictly increases per self-update (replay/rollback guard)
|
|
72
72
|
Sig text null, -- ed25519 self-signature over the signed payload (base64url); null until self-published
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
StampId text not null unique, -- single-use authorization nonce (anti-replay), bound into the voucher/remove digests; rotates on (re)insert
|
|
74
|
+
VouchOwner text null, -- ed25519 (base64url) owner key that vouched this membership (== insert context.OwnerKey)
|
|
75
|
+
VouchSig text null, -- that owner's signature over digest(PeerId, StampId) (== insert context.Signature). A reader checks VouchOwner against its NODE-LOCAL trusted-owner anchor (not this replicated table, which a self-owner can pollute) to decide authorized membership.
|
|
76
|
+
constraint AuthorizedInsert check on insert (
|
|
77
|
+
-- Authorized by an owner key, which vouches both membership and the
|
|
75
78
|
-- PublicKey<->PeerId binding (cadre-core derives PublicKey from PeerId before insert).
|
|
76
|
-
-- The
|
|
77
|
-
-- cadre-core peer-authorization.ts:
|
|
78
|
-
exists (select 1 from
|
|
79
|
+
-- The digest binds PeerId + the single-use StampId nonce (two TEXT fields),
|
|
80
|
+
-- matching cadre-core peer-authorization.ts:cadrePeerVoucherDigest.
|
|
81
|
+
exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.PeerId, new.StampId), context.Signature, A.Key, 'ed25519'))
|
|
82
|
+
-- Persist the vouching (owner, signature) onto the row so a reader can later
|
|
83
|
+
-- re-check VouchOwner against its node-local anchor. The stored pair MUST equal
|
|
84
|
+
-- the pair the verify above validated, so a writer cannot store a voucher it did
|
|
85
|
+
-- not actually receive a signature for.
|
|
86
|
+
and new.VouchOwner = context.OwnerKey
|
|
87
|
+
and new.VouchSig = context.Signature
|
|
88
|
+
),
|
|
89
|
+
constraint AuthorizedDelete check on delete (
|
|
90
|
+
-- Delete is authorized by a signature over a DISTINCT 'remove'-scoped digest bound
|
|
91
|
+
-- to the row's StampId (cadre-core peer-authorization.ts:cadrePeerRemoveDigest), so
|
|
92
|
+
-- the stored voucher (a signature over digest(PeerId, StampId)) can NEVER be replayed
|
|
93
|
+
-- to authorize a delete. The remove signature rides in context and is never stored.
|
|
94
|
+
exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(old.PeerId, old.StampId, 'remove'), context.Signature, A.Key, 'ed25519'))
|
|
79
95
|
),
|
|
80
96
|
constraint AuthorizedUpdate check on update (
|
|
81
97
|
-- Peer self-updates its own addrs + freshness, signing with its OWN ed25519 key
|
|
82
|
-
-- (the key behind PeerId). PeerId/PublicKey are immutable on
|
|
83
|
-
-- must strictly increase (replay guard), and Sig is verified
|
|
84
|
-
-- PublicKey over the same payload the publish path signs
|
|
98
|
+
-- (the key behind PeerId). PeerId/PublicKey/StampId/voucher are immutable on
|
|
99
|
+
-- self-update, UpdatedAt must strictly increase (replay guard), and Sig is verified
|
|
100
|
+
-- against the stored PublicKey over the same payload the publish path signs
|
|
85
101
|
-- (cadre-core peer-record.ts:peerRecordSignedPayload).
|
|
86
102
|
(
|
|
87
103
|
new.PeerId = old.PeerId
|
|
88
104
|
and new.PublicKey = old.PublicKey
|
|
105
|
+
and new.StampId = old.StampId
|
|
106
|
+
and new.VouchOwner = old.VouchOwner
|
|
107
|
+
and new.VouchSig = old.VouchSig
|
|
89
108
|
and new.UpdatedAt > coalesce(old.UpdatedAt, 0)
|
|
90
109
|
and verify(
|
|
91
110
|
digest(new.PeerId || '|' || new.Multiaddr || '|' || cast(new.UpdatedAt as text)),
|
|
92
111
|
new.Sig, new.PublicKey, 'ed25519')
|
|
93
112
|
)
|
|
94
|
-
-- or an
|
|
95
|
-
|
|
113
|
+
-- or an owner re-authorizes (rotation / correction): re-vouches the row over
|
|
114
|
+
-- its current StampId, so the stored voucher is re-bound to the re-authorizing owner.
|
|
115
|
+
or (
|
|
116
|
+
exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.PeerId, new.StampId), context.Signature, A.Key, 'ed25519'))
|
|
117
|
+
and new.VouchOwner = context.OwnerKey
|
|
118
|
+
and new.VouchSig = context.Signature
|
|
119
|
+
)
|
|
96
120
|
)
|
|
97
|
-
) with context (
|
|
121
|
+
) with context (OwnerKey text null, Signature text);
|
|
98
122
|
|
|
99
123
|
-- A mobile cadre peer's platform push token (FCM/APNs), self-published so a
|
|
100
124
|
-- server peer can deliver a push-wake to a suspended app over the platform push
|
|
@@ -109,9 +133,9 @@ declare schema CadreControl {
|
|
|
109
133
|
UpdatedAt int null, -- epoch ms; strictly increases per self-update (replay guard)
|
|
110
134
|
Sig text null, -- ed25519 self-sig over (PeerId|Platform|Token|UpdatedAt)
|
|
111
135
|
constraint AuthorizedInsert check on insert, delete (
|
|
112
|
-
-- Authorized by an
|
|
136
|
+
-- Authorized by an owner key (membership vouch), exactly like CadrePeer.
|
|
113
137
|
-- The single-field digest frames the base58btc PeerId as TEXT.
|
|
114
|
-
exists (select 1 from
|
|
138
|
+
exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(coalesce(new.PeerId, old.PeerId)), context.Signature, A.Key, 'ed25519'))
|
|
115
139
|
),
|
|
116
140
|
constraint AuthorizedUpdate check on update (
|
|
117
141
|
-- Peer self-updates its own token, signing with its OWN ed25519 key (the key
|
|
@@ -126,10 +150,10 @@ declare schema CadreControl {
|
|
|
126
150
|
digest(new.PeerId || '|' || new.Platform || '|' || new.Token || '|' || cast(new.UpdatedAt as text)),
|
|
127
151
|
new.Sig, P.PublicKey, 'ed25519'))
|
|
128
152
|
)
|
|
129
|
-
-- or an
|
|
130
|
-
or exists (select 1 from
|
|
153
|
+
-- or an owner re-authorizes (rotation / correction)
|
|
154
|
+
or exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(digest(new.PeerId), context.Signature, A.Key, 'ed25519'))
|
|
131
155
|
)
|
|
132
|
-
) with context (
|
|
156
|
+
) with context (OwnerKey text null, Signature text);
|
|
133
157
|
|
|
134
158
|
-- An open invitation to form a strand with this party
|
|
135
159
|
table FormationInvite (
|
|
@@ -141,11 +165,11 @@ declare schema CadreControl {
|
|
|
141
165
|
StrandId text null, -- Host strand this invite binds to (provision-then-record). Non-null => the responder records consent against this pre-existing strand; null => legacy responder-provisions path.
|
|
142
166
|
StampId text not null unique, -- single-use authorization nonce (anti-replay)
|
|
143
167
|
constraint AuthorizedAddOrRemove check on insert, delete (
|
|
144
|
-
-- Authorized by an
|
|
168
|
+
-- Authorized by an owner signing over THIS row
|
|
145
169
|
-- (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId); single-use via unique StampId.
|
|
146
170
|
-- coalesce(new.F, old.F) binds the NEW row on insert and the OLD row on delete (cf. CadrePeer).
|
|
147
171
|
-- StrandId is a nullable bound field: it signs as '' when absent (cf. ValidationUrl).
|
|
148
|
-
exists (select 1 from
|
|
172
|
+
exists (select 1 from OwnerKey A where A.Key = context.OwnerKey and verify(
|
|
149
173
|
digest(
|
|
150
174
|
coalesce(new.Token, old.Token),
|
|
151
175
|
coalesce(new.sAppId, old.sAppId),
|
|
@@ -163,7 +187,7 @@ declare schema CadreControl {
|
|
|
163
187
|
-- defaults to insert+update; the check on insert, delete above excludes update,
|
|
164
188
|
-- so this guard is required. Mirrors FormationUsage.InsertOnly.
|
|
165
189
|
constraint Immutable check on update (false)
|
|
166
|
-
) with context (
|
|
190
|
+
) with context (OwnerKey text, Signature text);
|
|
167
191
|
|
|
168
192
|
table FormationUsage (
|
|
169
193
|
Token text,
|