@kubun/plugin-p2p 0.13.1 → 0.15.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/lib/context/peer.js +250 -3
- package/lib/context/sync.js +134 -25
- package/lib/context/types.d.ts +7 -0
- package/lib/groups/broadcast-message.d.ts +29 -0
- package/lib/groups/broadcast.d.ts +11 -1
- package/lib/groups/broadcast.js +44 -2
- package/lib/groups/credential-apply.d.ts +64 -2
- package/lib/groups/credential-apply.js +215 -30
- package/lib/groups/credential-grant.d.ts +22 -0
- package/lib/groups/credential-grant.js +76 -2
- package/lib/groups/credential-manifest-token.d.ts +31 -0
- package/lib/groups/credential-manifest-token.js +49 -0
- package/lib/groups/credential-readiness.d.ts +69 -0
- package/lib/groups/credential-readiness.js +172 -0
- package/lib/groups/credential-wrapping-deps.d.ts +23 -0
- package/lib/groups/credential-wrapping-deps.js +25 -0
- package/lib/groups/grantor-authority.d.ts +65 -0
- package/lib/groups/grantor-authority.js +107 -0
- package/lib/groups/group-handlers.js +7 -0
- package/lib/groups/group-peer-manager.d.ts +25 -0
- package/lib/groups/group-peer-manager.js +71 -0
- package/lib/groups/group-protocols.d.ts +47 -0
- package/lib/groups/group-protocols.js +28 -0
- package/lib/hub/wiring.d.ts +24 -0
- package/lib/hub/wiring.js +17 -1
- package/lib/index.d.ts +14 -0
- package/lib/index.js +157 -6
- package/lib/peer/blob-fetch.d.ts +2 -18
- package/lib/protocol.d.ts +30 -0
- package/lib/protocol.js +36 -0
- package/lib/schema.d.ts +16 -1
- package/lib/schema.js +113 -4
- package/lib/sync/group-sync-workflow.d.ts +77 -0
- package/lib/sync/group-sync-workflow.js +96 -0
- package/lib/sync/handlers.js +21 -2
- package/lib/sync/held-delegations.d.ts +14 -0
- package/lib/sync/held-delegations.js +34 -0
- package/lib/sync/hub-tunnel-service-listener.d.ts +75 -0
- package/lib/sync/hub-tunnel-service-listener.js +289 -0
- package/lib/sync/hub-tunnel-service-provider.d.ts +46 -0
- package/lib/sync/hub-tunnel-service-provider.js +100 -0
- package/lib/sync/service-tunnel-listeners.d.ts +35 -0
- package/lib/sync/service-tunnel-listeners.js +165 -0
- package/lib/sync/sync-manager.d.ts +7 -0
- package/lib/sync/sync-manager.js +4 -1
- package/lib/sync/tunnel-topics.d.ts +19 -1
- package/lib/sync/tunnel-topics.js +7 -3
- package/lib/types.d.ts +182 -7
- package/lib/util/handler-error.d.ts +8 -5
- package/lib/util/handler-error.js +10 -23
- package/package.json +51 -46
package/lib/groups/broadcast.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createReceiveAccessGate } from '../sync/receive-access-gate.js';
|
|
|
6
6
|
import { applyAccessDefaultSetFrame } from './access-default-apply.js';
|
|
7
7
|
import { verifyAccessDefault } from './access-default-token.js';
|
|
8
8
|
import { verifyCatalog } from './catalog-token.js';
|
|
9
|
-
import { applyCredentialKeyGrantFrame } from './credential-apply.js';
|
|
9
|
+
import { applyCredentialKeyGrantFrame, applyCredentialManifestFrame } from './credential-apply.js';
|
|
10
10
|
import { storeReceivedGrant } from './store-received-grant.js';
|
|
11
11
|
import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
12
12
|
/**
|
|
@@ -229,6 +229,12 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
|
229
229
|
applied: false
|
|
230
230
|
};
|
|
231
231
|
}
|
|
232
|
+
// Both halves or neither -- a partial source is unwired, so the
|
|
233
|
+
// delegated wrapping fails closed.
|
|
234
|
+
const credentialDeps = params.credentialControllerResolver != null && params.credentialRevocationChecker != null ? {
|
|
235
|
+
controllerResolver: params.credentialControllerResolver,
|
|
236
|
+
revocationChecker: params.credentialRevocationChecker
|
|
237
|
+
} : undefined;
|
|
232
238
|
const applied = await applyCredentialKeyGrantFrame({
|
|
233
239
|
store: params.credentialStore,
|
|
234
240
|
frame: message,
|
|
@@ -240,7 +246,43 @@ import { storeReceivedRevocation } from './store-received-revocation.js';
|
|
|
240
246
|
...params.logger != null ? {
|
|
241
247
|
logger: params.logger
|
|
242
248
|
} : {},
|
|
243
|
-
groupID
|
|
249
|
+
groupID,
|
|
250
|
+
...credentialDeps != null ? {
|
|
251
|
+
deps: credentialDeps
|
|
252
|
+
} : {}
|
|
253
|
+
});
|
|
254
|
+
return {
|
|
255
|
+
applied
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
case 'credential:key-manifest':
|
|
259
|
+
{
|
|
260
|
+
if (params.credentialStore == null) {
|
|
261
|
+
params.logger?.warn('credential:key-manifest dropped: no credential store on this device', {
|
|
262
|
+
groupID
|
|
263
|
+
});
|
|
264
|
+
return {
|
|
265
|
+
applied: false
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
// Both halves or neither, as the grant case above -- a partial source
|
|
269
|
+
// is unwired, so the manifest fails closed.
|
|
270
|
+
const credentialDeps = params.credentialControllerResolver != null && params.credentialRevocationChecker != null ? {
|
|
271
|
+
controllerResolver: params.credentialControllerResolver,
|
|
272
|
+
revocationChecker: params.credentialRevocationChecker
|
|
273
|
+
} : undefined;
|
|
274
|
+
const applied = await applyCredentialManifestFrame({
|
|
275
|
+
store: params.credentialStore,
|
|
276
|
+
frame: message,
|
|
277
|
+
selfDID: params.selfDID,
|
|
278
|
+
maxDriftMS: params.maxDriftMS ?? DEFAULT_MAX_DRIFT_MS,
|
|
279
|
+
...params.logger != null ? {
|
|
280
|
+
logger: params.logger
|
|
281
|
+
} : {},
|
|
282
|
+
groupID,
|
|
283
|
+
...credentialDeps != null ? {
|
|
284
|
+
deps: credentialDeps
|
|
285
|
+
} : {}
|
|
244
286
|
});
|
|
245
287
|
return {
|
|
246
288
|
applied
|
|
@@ -2,6 +2,7 @@ import { HLC } from '@kubun/hlc';
|
|
|
2
2
|
import type { Logger } from '@kubun/logger';
|
|
3
3
|
import type { CredentialStoreAPI } from '@kubun/store-credential';
|
|
4
4
|
import type { CredentialKeyBundle, GroupBroadcastMessage } from './broadcast-message.js';
|
|
5
|
+
import { type DelegatedWrappingCheckDeps } from './grantor-authority.js';
|
|
5
6
|
export type ApplyCredentialKeyGrantFrameParams = {
|
|
6
7
|
store: CredentialStoreAPI;
|
|
7
8
|
frame: Extract<GroupBroadcastMessage, {
|
|
@@ -24,6 +25,11 @@ export type ApplyCredentialKeyGrantFrameParams = {
|
|
|
24
25
|
maxDriftMS: number;
|
|
25
26
|
logger?: Logger;
|
|
26
27
|
groupID?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Present-time admission gate for a delegate-signed wrapping, forwarded to
|
|
30
|
+
* {@link applyCredentialKeyRows}. Unwired means the wrapping refuses (fail-closed).
|
|
31
|
+
*/
|
|
32
|
+
deps?: DelegatedWrappingCheckDeps;
|
|
27
33
|
};
|
|
28
34
|
/**
|
|
29
35
|
* Apply a signed `credential:key-grant`: the key's public record, the one
|
|
@@ -42,6 +48,42 @@ export type ApplyCredentialKeyGrantFrameParams = {
|
|
|
42
48
|
* Returns whether anything was written.
|
|
43
49
|
*/
|
|
44
50
|
export declare function applyCredentialKeyGrantFrame(params: ApplyCredentialKeyGrantFrameParams): Promise<boolean>;
|
|
51
|
+
export type ApplyCredentialManifestFrameParams = {
|
|
52
|
+
store: CredentialStoreAPI;
|
|
53
|
+
frame: Extract<GroupBroadcastMessage, {
|
|
54
|
+
type: 'credential:key-manifest';
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* This device, for the recipient check: a manifest fans out group-wide but
|
|
58
|
+
* names ONE recipient in its signed claim.
|
|
59
|
+
*/
|
|
60
|
+
selfDID: string;
|
|
61
|
+
/**
|
|
62
|
+
* Future-drift bound applied to the manifest's `issuedAt`. `issuedAt` is a
|
|
63
|
+
* drift bound ONLY -- ordering/authorization is `sequence`.
|
|
64
|
+
*/
|
|
65
|
+
maxDriftMS: number;
|
|
66
|
+
logger?: Logger;
|
|
67
|
+
groupID?: string;
|
|
68
|
+
/**
|
|
69
|
+
* The administer-chain check's deps. Unwired means the gate refuses
|
|
70
|
+
* (fail-closed) -- a manifest is stored only when its chain check passes.
|
|
71
|
+
*/
|
|
72
|
+
deps?: DelegatedWrappingCheckDeps;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Apply a `credential:key-manifest`: verify the token, require the grantor's
|
|
76
|
+
* present-time `credential/administer` chain over the owner, require
|
|
77
|
+
* `recipientDID` to name this device, bound `issuedAt` against future drift,
|
|
78
|
+
* then store under the lexicographic `(epoch, sequence)` rule.
|
|
79
|
+
*
|
|
80
|
+
* ADVISORY only -- a recipient proves readiness by decrypt-verifying its own
|
|
81
|
+
* keys, never by trusting this record. Fail-CLOSED: any check failure drops
|
|
82
|
+
* without storing.
|
|
83
|
+
*
|
|
84
|
+
* Returns whether a row was stored.
|
|
85
|
+
*/
|
|
86
|
+
export declare function applyCredentialManifestFrame(params: ApplyCredentialManifestFrameParams): Promise<boolean>;
|
|
45
87
|
/** A revoked subject's tombstone as the reconcile lane ships it. */
|
|
46
88
|
export type CredentialReconcileTombstone = {
|
|
47
89
|
keyID: string;
|
|
@@ -59,20 +101,40 @@ export type ApplyCredentialReconcileParams = {
|
|
|
59
101
|
bundles: Array<CredentialKeyBundle>;
|
|
60
102
|
/** Tombstones for advertised held IDs the server reports revoked. */
|
|
61
103
|
tombstones: Array<CredentialReconcileTombstone>;
|
|
104
|
+
/**
|
|
105
|
+
* Manifests riding with this catch-up response, applied via
|
|
106
|
+
* {@link applyCredentialManifestFrame} BEFORE the bundles below -- the
|
|
107
|
+
* fail-safe order: a crash mid-apply leaves the expectation recorded with
|
|
108
|
+
* keys still to follow, never keys landed with nothing that predicted them.
|
|
109
|
+
*/
|
|
110
|
+
manifests?: Array<{
|
|
111
|
+
manifest: string;
|
|
112
|
+
delegationTokens: Array<string>;
|
|
113
|
+
}>;
|
|
62
114
|
hlc?: HLC;
|
|
63
115
|
/** Future-drift bound on the granter's stamps. Defaults to {@link DEFAULT_MAX_DRIFT_MS}. */
|
|
64
116
|
maxDriftMS?: number;
|
|
65
117
|
logger?: Logger;
|
|
118
|
+
/** Forwarded to {@link applyCredentialKeyRows} and {@link applyCredentialManifestFrame}. */
|
|
119
|
+
deps?: DelegatedWrappingCheckDeps;
|
|
66
120
|
};
|
|
67
121
|
export type ApplyCredentialReconcileResult = {
|
|
122
|
+
/** Manifests that wrote a row. */
|
|
123
|
+
manifestsApplied: number;
|
|
68
124
|
/** Bundles that wrote rows. */
|
|
69
125
|
applied: number;
|
|
70
126
|
/** Tombstones that deleted a row. */
|
|
71
127
|
tombstoned: number;
|
|
72
128
|
};
|
|
73
129
|
/**
|
|
74
|
-
* Apply a credential reconcile response:
|
|
75
|
-
* recipient missed, then GC the rows a
|
|
130
|
+
* Apply a credential reconcile response: store the manifests it carries,
|
|
131
|
+
* materialise the catch-up bundles a recipient missed, then GC the rows a
|
|
132
|
+
* tombstone revokes — manifests first, so readiness never transiently
|
|
133
|
+
* under-declares.
|
|
134
|
+
*
|
|
135
|
+
* Runs as ONE atomic unit via `store.transaction`, which is nesting-safe
|
|
136
|
+
* (mirrors `writeAtomically`), so this never throws a Kysely nested-tx error
|
|
137
|
+
* even when `store` already runs inside an ambient transaction.
|
|
76
138
|
*
|
|
77
139
|
* The server's scoping is NOT trusted. Every row re-verifies its op signature
|
|
78
140
|
* and every wrapping re-checks it is addressed to this device before a write,
|
|
@@ -4,6 +4,8 @@ import { HLC } from '@kubun/hlc';
|
|
|
4
4
|
import { DEFAULT_MAX_DRIFT_MS } from '@kubun/mutation';
|
|
5
5
|
import { fromB64U } from '@sozai/codec';
|
|
6
6
|
import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappingDigest, verifyCredentialKeyGrant } from './credential-grant-token.js';
|
|
7
|
+
import { verifyCredentialManifest } from './credential-manifest-token.js';
|
|
8
|
+
import { authorizeDelegatedWrapping, checkAdministerChain, verifyGrantorAuthority } from './grantor-authority.js';
|
|
7
9
|
/**
|
|
8
10
|
* Apply a signed `credential:key-grant`: the key's public record, the one
|
|
9
11
|
* wrapping addressed to this device, and every entry ciphertext at that version.
|
|
@@ -51,7 +53,10 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
|
|
|
51
53
|
groupID,
|
|
52
54
|
bundle: frame,
|
|
53
55
|
requireIssuer: verified.issuer,
|
|
54
|
-
label: 'credential:key-grant'
|
|
56
|
+
label: 'credential:key-grant',
|
|
57
|
+
...params.deps != null ? {
|
|
58
|
+
deps: params.deps
|
|
59
|
+
} : {}
|
|
55
60
|
});
|
|
56
61
|
}
|
|
57
62
|
/**
|
|
@@ -201,6 +206,72 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
|
|
|
201
206
|
branchID: op.claim.branchID ?? ''
|
|
202
207
|
};
|
|
203
208
|
};
|
|
209
|
+
/**
|
|
210
|
+
* As {@link acceptOp}, but for the WRAPPING subject also accepts an op
|
|
211
|
+
* signed by a delegate `G` the owner has authorized (entry/key-branch ops
|
|
212
|
+
* stay on `acceptOp`'s owner-only rule). An op byte-identical to what this
|
|
213
|
+
* device already holds is accepted as trusted local state with no
|
|
214
|
+
* authorization check; otherwise `bundle.grantorAuthority` must be present
|
|
215
|
+
* and `authorizeDelegatedWrapping` must return `true`.
|
|
216
|
+
*/ const acceptWrappingOp = async (token, subject)=>{
|
|
217
|
+
const verified = await verifyCredentialOp(token);
|
|
218
|
+
if (verified == null || !credentialOpMatchesPut(verified.claim, subject)) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const opInput = {
|
|
222
|
+
keyID: verified.claim.keyID,
|
|
223
|
+
authorDID: verified.issuer,
|
|
224
|
+
hlc: verified.claim.hlc,
|
|
225
|
+
opJWT: token,
|
|
226
|
+
opHash: digestParts([
|
|
227
|
+
token
|
|
228
|
+
]),
|
|
229
|
+
branchID: verified.claim.branchID ?? ''
|
|
230
|
+
};
|
|
231
|
+
if (verified.issuer === issuer) {
|
|
232
|
+
return {
|
|
233
|
+
op: opInput
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// Hash computed the same way the store persists `op_hash` (mirrors
|
|
237
|
+
// `acceptOp`) -- a mismatch would fail-open the held short-circuit below
|
|
238
|
+
// in either direction.
|
|
239
|
+
const incomingHash = opInput.opHash;
|
|
240
|
+
const stored = await store.getOp('wrapping', bundle.wrapping.wrappingID);
|
|
241
|
+
if (stored?.op_hash === incomingHash) {
|
|
242
|
+
// Already-admitted op: trusted local state, no re-authorization or
|
|
243
|
+
// envelope persist needed.
|
|
244
|
+
return {
|
|
245
|
+
op: opInput
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (bundle.grantorAuthority == null) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const authorized = await authorizeDelegatedWrapping({
|
|
252
|
+
envelope: bundle.grantorAuthority,
|
|
253
|
+
wrappingOpIssuer: verified.issuer,
|
|
254
|
+
wrappingOpHash: incomingHash,
|
|
255
|
+
ownerDID: issuer,
|
|
256
|
+
deps: params.deps ?? {}
|
|
257
|
+
});
|
|
258
|
+
if (!authorized) {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
// Re-verified only to recover `refreshSeq` for the provenance write below
|
|
262
|
+
// -- `authorized === true` already proved the binding.
|
|
263
|
+
const claim = await verifyGrantorAuthority(bundle.grantorAuthority);
|
|
264
|
+
if (claim == null) {
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
op: opInput,
|
|
269
|
+
authority: {
|
|
270
|
+
envelope: bundle.grantorAuthority,
|
|
271
|
+
refreshSeq: claim.refreshSeq
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
};
|
|
204
275
|
const keyOpHash = digestParts([
|
|
205
276
|
bundle.keyOp
|
|
206
277
|
]);
|
|
@@ -242,7 +313,7 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
|
|
|
242
313
|
branchID: `${verified.claim.hlc}:${opHash}`
|
|
243
314
|
});
|
|
244
315
|
}
|
|
245
|
-
const
|
|
316
|
+
const acceptedWrapping = await acceptWrappingOp(bundle.wrapping.op, {
|
|
246
317
|
kind: 'wrapping',
|
|
247
318
|
params: wrappingParams
|
|
248
319
|
});
|
|
@@ -250,13 +321,14 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
|
|
|
250
321
|
kind: 'entry',
|
|
251
322
|
params: put.params
|
|
252
323
|
})));
|
|
253
|
-
if (
|
|
324
|
+
if (acceptedWrapping == null || entryOps.some((op)=>op == null)) {
|
|
254
325
|
logger?.warn(`${label} dropped: an op does not speak for the row it travels with`, {
|
|
255
326
|
groupID,
|
|
256
327
|
keyID: bundle.keyID
|
|
257
328
|
});
|
|
258
329
|
return false;
|
|
259
330
|
}
|
|
331
|
+
const wrappingOp = acceptedWrapping.op;
|
|
260
332
|
const provenBranchIDs = new Set(keyBranches.map((branch)=>branch.branchID));
|
|
261
333
|
for (const rowOp of [
|
|
262
334
|
wrappingOp,
|
|
@@ -302,7 +374,10 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
|
|
|
302
374
|
branchID: branch.branchID
|
|
303
375
|
});
|
|
304
376
|
}
|
|
305
|
-
|
|
377
|
+
// Envelope, when present, writes in the SAME transaction as the wrapping
|
|
378
|
+
// row -- a crash between the two must never leave a wrapping with no
|
|
379
|
+
// provenance record.
|
|
380
|
+
await store.putWrapping(wrappingParams, wrappingOp, acceptedWrapping.authority);
|
|
306
381
|
for (const [index, put] of entryPuts.entries()){
|
|
307
382
|
await store.putEntry(put.params, entryOps[index]);
|
|
308
383
|
// Merged so a later local write sorts after what was received. Safe to
|
|
@@ -316,41 +391,151 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
|
|
|
316
391
|
return true;
|
|
317
392
|
}
|
|
318
393
|
/**
|
|
319
|
-
* Apply a credential
|
|
320
|
-
*
|
|
394
|
+
* Apply a `credential:key-manifest`: verify the token, require the grantor's
|
|
395
|
+
* present-time `credential/administer` chain over the owner, require
|
|
396
|
+
* `recipientDID` to name this device, bound `issuedAt` against future drift,
|
|
397
|
+
* then store under the lexicographic `(epoch, sequence)` rule.
|
|
398
|
+
*
|
|
399
|
+
* ADVISORY only -- a recipient proves readiness by decrypt-verifying its own
|
|
400
|
+
* keys, never by trusting this record. Fail-CLOSED: any check failure drops
|
|
401
|
+
* without storing.
|
|
402
|
+
*
|
|
403
|
+
* Returns whether a row was stored.
|
|
404
|
+
*/ export async function applyCredentialManifestFrame(params) {
|
|
405
|
+
const { store, frame, groupID, logger } = params;
|
|
406
|
+
const claim = await verifyCredentialManifest(frame.manifest);
|
|
407
|
+
if (claim == null) {
|
|
408
|
+
logger?.warn('credential:key-manifest dropped: token verification failed', {
|
|
409
|
+
groupID
|
|
410
|
+
});
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
// Ordinary outcome for every co-member but one, so debug not warning
|
|
414
|
+
// (mirrors `applyCredentialKeyRows`'s wrapping-recipient check).
|
|
415
|
+
if (normalizeDID(claim.recipientDID) !== normalizeDID(params.selfDID)) {
|
|
416
|
+
logger?.debug('credential:key-manifest ignored: manifest is addressed elsewhere', {
|
|
417
|
+
groupID,
|
|
418
|
+
ownerDID: claim.ownerDID
|
|
419
|
+
});
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
// Present-time admission: grantor must hold a live `credential/administer`
|
|
423
|
+
// chain over the owner, proven by `delegationTokens`. Fail-closed on
|
|
424
|
+
// unwired deps -- the SAME gate a delegated wrapping passes.
|
|
425
|
+
//
|
|
426
|
+
// Self-issued exception (grantor === owner), mirroring
|
|
427
|
+
// `computeCredentialProvisioningStatus`: `checkAdministerChain`'s synthetic
|
|
428
|
+
// top-level check doesn't apply to a self-grant. Safe because `grantorDID`
|
|
429
|
+
// comes only from the VERIFIED `iss`, never a payload field, so forging
|
|
430
|
+
// `grantorDID === ownerDID` requires already controlling the owner's
|
|
431
|
+
// signing key.
|
|
432
|
+
const authorized = normalizeDID(claim.grantorDID) === normalizeDID(claim.ownerDID) ? true : await checkAdministerChain({
|
|
433
|
+
grantorDID: claim.grantorDID,
|
|
434
|
+
ownerDID: claim.ownerDID,
|
|
435
|
+
delegationTokens: frame.delegationTokens,
|
|
436
|
+
deps: params.deps ?? {}
|
|
437
|
+
});
|
|
438
|
+
if (!authorized) {
|
|
439
|
+
logger?.warn('credential:key-manifest dropped: grantor holds no administer chain over the owner', {
|
|
440
|
+
groupID,
|
|
441
|
+
ownerDID: claim.ownerDID
|
|
442
|
+
});
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
// Future-drift bound only, unlike the grant lane's entry stamps: a manifest
|
|
446
|
+
// leaves no op row for the sync lane to order, so this never floors the
|
|
447
|
+
// device clock.
|
|
448
|
+
const stamp = HLC.tryParse(claim.issuedAt);
|
|
449
|
+
if (stamp == null || stamp.wallTime - Date.now() > params.maxDriftMS) {
|
|
450
|
+
logger?.warn('credential:key-manifest dropped: issuedAt stamp is unusable', {
|
|
451
|
+
groupID,
|
|
452
|
+
issuedAt: claim.issuedAt
|
|
453
|
+
});
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
// Replay check mirrors the store's own upsert rule (so `applied` reports
|
|
457
|
+
// accurately): lexicographic (epoch, sequence) -- a higher epoch always
|
|
458
|
+
// wins; within the same epoch, lower-or-equal sequence is replay.
|
|
459
|
+
const existing = await store.listManifests({
|
|
460
|
+
ownerDID: claim.ownerDID,
|
|
461
|
+
recipientDID: claim.recipientDID
|
|
462
|
+
});
|
|
463
|
+
const prior = existing.find((manifest)=>manifest.record.grantorDID === claim.grantorDID);
|
|
464
|
+
if (prior != null && (prior.record.epoch > claim.epoch || prior.record.epoch === claim.epoch && claim.sequence <= prior.record.sequence)) {
|
|
465
|
+
logger?.warn('credential:key-manifest dropped: replayed or stale epoch/sequence', {
|
|
466
|
+
groupID,
|
|
467
|
+
epoch: claim.epoch,
|
|
468
|
+
sequence: claim.sequence
|
|
469
|
+
});
|
|
470
|
+
return false;
|
|
471
|
+
}
|
|
472
|
+
await store.putManifest(claim, frame.manifest, frame.delegationTokens);
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Apply a credential reconcile response: store the manifests it carries,
|
|
477
|
+
* materialise the catch-up bundles a recipient missed, then GC the rows a
|
|
478
|
+
* tombstone revokes — manifests first, so readiness never transiently
|
|
479
|
+
* under-declares.
|
|
480
|
+
*
|
|
481
|
+
* Runs as ONE atomic unit via `store.transaction`, which is nesting-safe
|
|
482
|
+
* (mirrors `writeAtomically`), so this never throws a Kysely nested-tx error
|
|
483
|
+
* even when `store` already runs inside an ambient transaction.
|
|
321
484
|
*
|
|
322
485
|
* The server's scoping is NOT trusted. Every row re-verifies its op signature
|
|
323
486
|
* and every wrapping re-checks it is addressed to this device before a write,
|
|
324
487
|
* exactly as an unsolicited grant would — a reconcile is a batch of the same
|
|
325
488
|
* self-authenticating rows, pulled instead of pushed.
|
|
326
489
|
*/ export async function applyCredentialReconcile(params) {
|
|
327
|
-
const {
|
|
490
|
+
const { selfDID, logger } = params;
|
|
328
491
|
const maxDriftMS = params.maxDriftMS ?? DEFAULT_MAX_DRIFT_MS;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
492
|
+
return await params.store.transaction(async (store)=>{
|
|
493
|
+
let manifestsApplied = 0;
|
|
494
|
+
for (const { manifest, delegationTokens } of params.manifests ?? []){
|
|
495
|
+
const wrote = await applyCredentialManifestFrame({
|
|
496
|
+
store,
|
|
497
|
+
selfDID,
|
|
498
|
+
frame: {
|
|
499
|
+
type: 'credential:key-manifest',
|
|
500
|
+
manifest,
|
|
501
|
+
delegationTokens
|
|
502
|
+
},
|
|
503
|
+
maxDriftMS,
|
|
504
|
+
logger,
|
|
505
|
+
deps: params.deps
|
|
506
|
+
});
|
|
507
|
+
if (wrote) {
|
|
508
|
+
manifestsApplied += 1;
|
|
509
|
+
}
|
|
342
510
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
511
|
+
let applied = 0;
|
|
512
|
+
for (const bundle of params.bundles){
|
|
513
|
+
const wrote = await applyCredentialKeyRows({
|
|
514
|
+
store,
|
|
515
|
+
selfDID,
|
|
516
|
+
hlc: params.hlc,
|
|
517
|
+
maxDriftMS,
|
|
518
|
+
logger,
|
|
519
|
+
bundle,
|
|
520
|
+
label: 'credential:reconcile',
|
|
521
|
+
deps: params.deps
|
|
522
|
+
});
|
|
523
|
+
if (wrote) {
|
|
524
|
+
applied += 1;
|
|
525
|
+
}
|
|
348
526
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
527
|
+
let tombstoned = 0;
|
|
528
|
+
for (const tombstone of params.tombstones){
|
|
529
|
+
if (await applyReconcileTombstone(store, tombstone, logger)) {
|
|
530
|
+
tombstoned += 1;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
manifestsApplied,
|
|
535
|
+
applied,
|
|
536
|
+
tombstoned
|
|
537
|
+
};
|
|
538
|
+
});
|
|
354
539
|
}
|
|
355
540
|
/**
|
|
356
541
|
* Delete one revoked subject, keying on the tombstone op it carries.
|
|
@@ -62,7 +62,28 @@ export type GrantCredentialKeyParams = {
|
|
|
62
62
|
* will resolve a request authority is a follow-up.
|
|
63
63
|
*/
|
|
64
64
|
authority?: CredentialAuthority;
|
|
65
|
+
/**
|
|
66
|
+
* The credential key's owner and signing device `G`. Present and DISTINCT
|
|
67
|
+
* iff delegate-signed (absent, or equal, for an owner-signed grant, which
|
|
68
|
+
* needs no envelope). `identity` must resolve to `grantorDID` when
|
|
69
|
+
* delegate-signed -- `signGrantorAuthority` signs with `identity`, not
|
|
70
|
+
* this field.
|
|
71
|
+
*/
|
|
72
|
+
ownerDID?: string;
|
|
73
|
+
grantorDID?: string;
|
|
74
|
+
/**
|
|
75
|
+
* `G`'s current administer capability chain, carried in the envelope for
|
|
76
|
+
* present-time admission checks. Ignored outside the delegate-signed path.
|
|
77
|
+
*/
|
|
78
|
+
delegationTokens?: Array<string>;
|
|
65
79
|
};
|
|
80
|
+
/**
|
|
81
|
+
* What a grant call settled as, beyond `created`. `grantCredentialKeyToMember`
|
|
82
|
+
* only ever produces `'granted'` or `'refreshed'` -- `'not-held'`/`'error'`
|
|
83
|
+
* are reserved for callers, kept here so nothing downstream needs a type
|
|
84
|
+
* this module doesn't produce.
|
|
85
|
+
*/
|
|
86
|
+
export type CredentialGrantOutcomeKind = 'granted' | 'refreshed' | 'not-held' | 'error';
|
|
66
87
|
export type GrantCredentialKeyResult = {
|
|
67
88
|
version: number;
|
|
68
89
|
/**
|
|
@@ -70,6 +91,7 @@ export type GrantCredentialKeyResult = {
|
|
|
70
91
|
* way: a repeat grant is how an undelivered one is retried.
|
|
71
92
|
*/
|
|
72
93
|
created: boolean;
|
|
94
|
+
outcome: CredentialGrantOutcomeKind;
|
|
73
95
|
};
|
|
74
96
|
/**
|
|
75
97
|
* Grant a credential key to a co-member, resolving what to wrap to from the MLS
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { normalizeDID } from '@kokuin/token';
|
|
2
2
|
import { toB64U } from '@sozai/codec';
|
|
3
3
|
import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappingDigest, signCredentialKeyGrant } from './credential-grant-token.js';
|
|
4
|
+
import { signGrantorAuthority } from './grantor-authority.js';
|
|
4
5
|
/**
|
|
5
6
|
* The recipient of a grant holds no leaf in the group, so there is no
|
|
6
7
|
* authenticated document to wrap to.
|
|
@@ -69,13 +70,82 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
|
|
|
69
70
|
const result = await credentials.grant(keyID, wrappableDID, {
|
|
70
71
|
authority: params.authority
|
|
71
72
|
});
|
|
73
|
+
const outcome = await mintGrantorAuthorityIfDelegated({
|
|
74
|
+
store,
|
|
75
|
+
identity,
|
|
76
|
+
keyID,
|
|
77
|
+
wrappableDID,
|
|
78
|
+
result,
|
|
79
|
+
grant: params
|
|
80
|
+
});
|
|
72
81
|
// Read back NOW and dispatch on commit, not the reverse. The read has to run
|
|
73
82
|
// on the caller's connection while the transaction is still open, and it has
|
|
74
83
|
// to see the state the grant left rather than whatever a later write leaves —
|
|
75
84
|
// an entry written after this point belongs to its own `credential:entry-put`.
|
|
85
|
+
// Also reads back the envelope `mintGrantorAuthorityIfDelegated` just
|
|
86
|
+
// persisted (or none), via `readCredentialKeyBundle`'s `getWrappingAuthority`
|
|
87
|
+
// call — no second write path.
|
|
76
88
|
const frame = await readGrantFrame(store, identity, keyID, wrappableDID);
|
|
77
89
|
stores.onCommit(()=>params.scheduleBroadcast(groupID, frame));
|
|
78
|
-
return
|
|
90
|
+
return {
|
|
91
|
+
version: result.version,
|
|
92
|
+
created: result.created,
|
|
93
|
+
outcome
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Mint or refresh the beside-the-op grantor-authority envelope for a
|
|
98
|
+
* delegate-signed grant, and report the outcome.
|
|
99
|
+
*
|
|
100
|
+
* Gated on `ownerDID`/`grantorDID` present AND distinct -- an owner-signed
|
|
101
|
+
* grant needs no envelope (owner authority doesn't expire like a delegate's
|
|
102
|
+
* chain).
|
|
103
|
+
*
|
|
104
|
+
* Runs on BOTH first grant and re-grant: nothing else in this codebase mints
|
|
105
|
+
* a grantor-authority envelope, so a delegate-signed first grant must mint
|
|
106
|
+
* its envelope here too, or ship with none.
|
|
107
|
+
*/ async function mintGrantorAuthorityIfDelegated(params) {
|
|
108
|
+
const { store, identity, keyID, wrappableDID, result, grant } = params;
|
|
109
|
+
const { ownerDID, grantorDID } = grant;
|
|
110
|
+
if (ownerDID == null || grantorDID == null || normalizeDID(grantorDID) === normalizeDID(ownerDID)) {
|
|
111
|
+
return 'granted';
|
|
112
|
+
}
|
|
113
|
+
const wrappings = await store.listWrappings(keyID, result.version);
|
|
114
|
+
const wrapping = findWrappingForRecipient(wrappings, wrappableDID);
|
|
115
|
+
if (wrapping == null) {
|
|
116
|
+
throw new CredentialGrantNotReadable(keyID, `no wrapping for ${normalizeDID(wrappableDID)} at v${result.version}`);
|
|
117
|
+
}
|
|
118
|
+
// The SAME `op_hash` the store holds -- never recomputed, since this
|
|
119
|
+
// function has no token to recompute it from.
|
|
120
|
+
const op = await store.getOp('wrapping', wrapping.wrapping_id);
|
|
121
|
+
if (op == null || op.deleted !== 0) {
|
|
122
|
+
throw new CredentialGrantNotReadable(keyID, `no live op for wrapping ${wrapping.wrapping_id}`);
|
|
123
|
+
}
|
|
124
|
+
// The wrapping's own (un-normalized) recipient -- what a receiver compares
|
|
125
|
+
// `bundle.wrapping.recipientDID` against.
|
|
126
|
+
const recipientDID = wrapping.recipient_did ?? wrappableDID;
|
|
127
|
+
const refreshSeq = await store.nextGrantorSequence({
|
|
128
|
+
ownerDID,
|
|
129
|
+
recipientDID,
|
|
130
|
+
grantorDID
|
|
131
|
+
});
|
|
132
|
+
const envelope = await signGrantorAuthority(identity, {
|
|
133
|
+
wrappingOpHash: op.op_hash,
|
|
134
|
+
refreshSeq,
|
|
135
|
+
ownerDID,
|
|
136
|
+
recipientDID,
|
|
137
|
+
delegationTokens: grant.delegationTokens ?? []
|
|
138
|
+
});
|
|
139
|
+
await store.putWrappingAuthority({
|
|
140
|
+
wrappingID: wrapping.wrapping_id,
|
|
141
|
+
envelope,
|
|
142
|
+
refreshSeq
|
|
143
|
+
});
|
|
144
|
+
return result.created ? 'granted' : 'refreshed';
|
|
145
|
+
}
|
|
146
|
+
/** The wrapping addressed to `recipientWrappableDID`, or `undefined` when none. */ function findWrappingForRecipient(wrappings, recipientWrappableDID) {
|
|
147
|
+
const recipient = normalizeDID(recipientWrappableDID);
|
|
148
|
+
return wrappings.find((candidate)=>candidate.recipient_did != null && normalizeDID(candidate.recipient_did) === recipient);
|
|
79
149
|
}
|
|
80
150
|
/**
|
|
81
151
|
* Rebuild the frame from what the grant persisted, rather than from what the
|
|
@@ -134,6 +204,7 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
|
|
|
134
204
|
throw new CredentialGrantNotReadable(keyID, `no wrapping for ${recipient} at v${key.version}`);
|
|
135
205
|
}
|
|
136
206
|
const entries = await store.listEntries(keyID);
|
|
207
|
+
const authority = await store.getWrappingAuthority(wrapping.wrapping_id);
|
|
137
208
|
const carriedWrapping = {
|
|
138
209
|
wrappingID: wrapping.wrapping_id,
|
|
139
210
|
factors: wrapping.factors,
|
|
@@ -160,6 +231,9 @@ import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappin
|
|
|
160
231
|
keyOp: await readOp('key', keyID),
|
|
161
232
|
keyBranches: (await store.listKeyBranches(keyID, key.version)).map((branch)=>branch.op_jwt),
|
|
162
233
|
wrapping: carriedWrapping,
|
|
163
|
-
entries: carriedEntries
|
|
234
|
+
entries: carriedEntries,
|
|
235
|
+
...authority != null ? {
|
|
236
|
+
grantorAuthority: authority.envelope
|
|
237
|
+
} : {}
|
|
164
238
|
};
|
|
165
239
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type SigningIdentity } from '@kokuin/token';
|
|
2
|
+
/**
|
|
3
|
+
* What a grantor signs to record the set of keys it granted a recipient: a
|
|
4
|
+
* claim the recipient checks its store against to prove completeness. Domain
|
|
5
|
+
* separation from other signed payloads comes from the `kind` marker plus the
|
|
6
|
+
* structural check in `verifyCredentialManifest` -- `signToken` has no
|
|
7
|
+
* domain override of its own.
|
|
8
|
+
*/
|
|
9
|
+
export type CredentialManifestClaim = {
|
|
10
|
+
kind: 'credential/key-manifest';
|
|
11
|
+
ownerDID: string;
|
|
12
|
+
recipientDID: string;
|
|
13
|
+
grantorDID: string;
|
|
14
|
+
epoch: number;
|
|
15
|
+
sequence: number;
|
|
16
|
+
keys: Array<{
|
|
17
|
+
keyID: string;
|
|
18
|
+
version: number;
|
|
19
|
+
}>;
|
|
20
|
+
issuedAt: string;
|
|
21
|
+
};
|
|
22
|
+
/** The envelope carried on the wire and persisted is the signed token string itself. */
|
|
23
|
+
export type CredentialManifestEnvelope = string;
|
|
24
|
+
export declare function signCredentialManifest(identity: SigningIdentity, claim: Omit<CredentialManifestClaim, 'kind' | 'grantorDID'>): Promise<CredentialManifestEnvelope>;
|
|
25
|
+
/**
|
|
26
|
+
* Verify a credential-manifest envelope and extract its claim. Returns `null`
|
|
27
|
+
* (never throws) when the token is unparseable, unsigned (`alg: 'none'`), or
|
|
28
|
+
* structurally not a credential-manifest claim -- `grantorDID` is trusted
|
|
29
|
+
* only from the verified `iss`, never from a same-named field in the payload.
|
|
30
|
+
*/
|
|
31
|
+
export declare function verifyCredentialManifest(envelope: CredentialManifestEnvelope): Promise<CredentialManifestClaim | null>;
|