@kubun/plugin-p2p 0.12.0 → 0.13.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/group.js +3 -0
- package/lib/context/join.js +3 -0
- package/lib/context/peer.js +7 -2
- package/lib/context/sync.js +63 -5
- package/lib/context/types.d.ts +14 -1
- package/lib/context/types.js +12 -0
- package/lib/groups/access-default-apply.d.ts +42 -0
- package/lib/groups/access-default-apply.js +75 -0
- package/lib/groups/broadcast-codec.d.ts +1 -1
- package/lib/groups/broadcast-message.d.ts +240 -0
- package/lib/groups/broadcast-message.js +1 -0
- package/lib/groups/broadcast.d.ts +19 -266
- package/lib/groups/broadcast.js +30 -204
- package/lib/groups/credential-apply.d.ts +82 -0
- package/lib/groups/credential-apply.js +410 -0
- package/lib/groups/credential-grant-token.d.ts +42 -0
- package/lib/groups/credential-grant-token.js +51 -0
- package/lib/groups/credential-grant.d.ts +98 -0
- package/lib/groups/credential-grant.js +165 -0
- package/lib/groups/group-handlers.js +25 -2
- package/lib/groups/group-mls.d.ts +5 -0
- package/lib/groups/group-mls.js +4 -1
- package/lib/groups/group-peer-manager.d.ts +7 -1
- package/lib/groups/group-peer-manager.js +44 -1
- package/lib/groups/group-protocols.d.ts +227 -0
- package/lib/groups/group-protocols.js +146 -0
- package/lib/groups/join-utils.d.ts +5 -0
- package/lib/groups/join-utils.js +5 -1
- package/lib/groups/ledger-adopt.d.ts +84 -0
- package/lib/groups/ledger-adopt.js +142 -0
- package/lib/groups/ledger-commit-fold.d.ts +3 -1
- package/lib/groups/ledger-commit-fold.js +3 -0
- package/lib/groups/ledger-ingest.d.ts +23 -1
- package/lib/groups/ledger-ingest.js +30 -1
- package/lib/groups/manager.d.ts +5 -0
- package/lib/groups/manager.js +6 -1
- package/lib/groups/peer-presence.d.ts +9 -2
- package/lib/groups/peer-presence.js +14 -2
- package/lib/groups/peer-selection.d.ts +9 -0
- package/lib/groups/peer-selection.js +10 -0
- package/lib/hub/wiring.d.ts +6 -1
- package/lib/hub/wiring.js +2 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +58 -6
- package/lib/peer/blob-fetch.d.ts +45 -0
- package/lib/peer/blob-fetch.js +89 -0
- package/lib/peer/blob-handlers.d.ts +11 -0
- package/lib/peer/blob-handlers.js +123 -0
- package/lib/peer/controller-fetch.d.ts +6 -0
- package/lib/peer/controller-fetch.js +59 -0
- package/lib/peer/controller-handlers.d.ts +10 -0
- package/lib/peer/controller-handlers.js +28 -0
- package/lib/protocol.d.ts +279 -0
- package/lib/protocol.js +358 -0
- package/lib/sync/access-default-sender.d.ts +1 -1
- package/lib/sync/broadcast-queue.d.ts +1 -1
- package/lib/sync/forwarder.d.ts +1 -1
- package/lib/sync/handlers.js +98 -1
- package/lib/sync/sync-manager.d.ts +10 -1
- package/lib/sync/sync-manager.js +24 -2
- package/lib/types.d.ts +18 -0
- package/package.json +55 -43
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { normalizeDID } from '@kokuin/token';
|
|
2
|
+
import { credentialOpMatchesPut, digestParts, verifyCredentialOp } from '@kubun/credential';
|
|
3
|
+
import { HLC } from '@kubun/hlc';
|
|
4
|
+
import { DEFAULT_MAX_DRIFT_MS } from '@kubun/mutation';
|
|
5
|
+
import { fromB64U } from '@sozai/codec';
|
|
6
|
+
import { credentialEntriesDigest, credentialKeyBranchesDigest, credentialWrappingDigest, verifyCredentialKeyGrant } from './credential-grant-token.js';
|
|
7
|
+
/**
|
|
8
|
+
* Apply a signed `credential:key-grant`: the key's public record, the one
|
|
9
|
+
* wrapping addressed to this device, and every entry ciphertext at that version.
|
|
10
|
+
*
|
|
11
|
+
* Sender-binding here is DIRECTIONAL, unlike `access-default:set`'s. That rule
|
|
12
|
+
* ("the stored owner equals the verified issuer") presumes a row, and a grant is
|
|
13
|
+
* the first thing this device ever hears about the key:
|
|
14
|
+
*
|
|
15
|
+
* - key absent → MINT, with `owner_did` from the verified issuer. Accepting a
|
|
16
|
+
* key means accepting its signer as the principal who may rotate and revoke it.
|
|
17
|
+
* - key present → REQUIRE the stored `owner_did` to equal the issuer. That is
|
|
18
|
+
* what makes a re-sent frame a retry rather than a takeover: the original
|
|
19
|
+
* granter updates, anyone else is refused.
|
|
20
|
+
*
|
|
21
|
+
* Returns whether anything was written.
|
|
22
|
+
*/ export async function applyCredentialKeyGrantFrame(params) {
|
|
23
|
+
const { frame, groupID, logger } = params;
|
|
24
|
+
const verified = await verifyCredentialKeyGrant(frame.auth);
|
|
25
|
+
if (verified == null) {
|
|
26
|
+
logger?.warn('credential:key-grant dropped: token verification failed', {
|
|
27
|
+
groupID
|
|
28
|
+
});
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const claim = verified.claim;
|
|
32
|
+
// The plaintext frame is what gets stored, so every stored field has to be the
|
|
33
|
+
// one that was signed. `ownerDID` is deliberately absent from this list — it is
|
|
34
|
+
// a mirror the receiver ignores in favour of the issuer.
|
|
35
|
+
if (claim.keyID !== frame.keyID || claim.keyVersion !== frame.keyVersion || claim.suite !== frame.suite || claim.recipientDID !== frame.wrapping.recipientDID || claim.wrappingDigest !== credentialWrappingDigest(frame.wrapping) || claim.entriesDigest !== credentialEntriesDigest(frame.entries) || claim.keyBranchesDigest !== credentialKeyBranchesDigest(frame.keyBranches)) {
|
|
36
|
+
logger?.warn('credential:key-grant dropped: frame does not match the signed claim', {
|
|
37
|
+
groupID,
|
|
38
|
+
keyID: frame.keyID
|
|
39
|
+
});
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
// The frame's `auth` names one authority for the whole bundle, so every op has
|
|
43
|
+
// to speak for it too — `requireIssuer` carries that binding into the shared
|
|
44
|
+
// apply, which a reconcile bundle (no `auth`) omits.
|
|
45
|
+
return await applyCredentialKeyRows({
|
|
46
|
+
store: params.store,
|
|
47
|
+
selfDID: params.selfDID,
|
|
48
|
+
hlc: params.hlc,
|
|
49
|
+
maxDriftMS: params.maxDriftMS,
|
|
50
|
+
logger,
|
|
51
|
+
groupID,
|
|
52
|
+
bundle: frame,
|
|
53
|
+
requireIssuer: verified.issuer,
|
|
54
|
+
label: 'credential:key-grant'
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Write one key's public record, the one wrapping addressed to this device, and
|
|
59
|
+
* every entry ciphertext at that version — the row logic a grant and a reconcile
|
|
60
|
+
* bundle share.
|
|
61
|
+
*
|
|
62
|
+
* The key op's verified issuer is the authority: absent key → MINT with
|
|
63
|
+
* `owner_did` set to it; present key → REQUIRE the stored owner to equal it, so a
|
|
64
|
+
* re-sent bundle is a retry and anyone else is refused. Every wrapping and entry
|
|
65
|
+
* op must carry that same issuer, which is what keeps `author_did` identical on
|
|
66
|
+
* every device and blocks a foreign op riding in beside a real one.
|
|
67
|
+
*/ async function applyCredentialKeyRows(params) {
|
|
68
|
+
const { store, selfDID, logger, groupID, bundle, label } = params;
|
|
69
|
+
// Not addressed here — do NOT trust a server's scoping. On a grant fan-out this
|
|
70
|
+
// is the ordinary outcome for every co-member but one, so it is debug, not a
|
|
71
|
+
// warning; on a reconcile it is the misdelivery guard, dropping a co-recipient's
|
|
72
|
+
// wrapping a buggy or hostile server routed into this store.
|
|
73
|
+
if (normalizeDID(bundle.wrapping.recipientDID) !== normalizeDID(selfDID)) {
|
|
74
|
+
logger?.debug(`${label} ignored: wrapping is addressed elsewhere`, {
|
|
75
|
+
groupID,
|
|
76
|
+
keyID: bundle.keyID
|
|
77
|
+
});
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
// The key op self-authenticates its author; that author is the key's owner and
|
|
81
|
+
// the authority every other op in the bundle must share.
|
|
82
|
+
const keyVerified = await verifyCredentialOp(bundle.keyOp);
|
|
83
|
+
if (keyVerified == null) {
|
|
84
|
+
logger?.warn(`${label} dropped: key op verification failed`, {
|
|
85
|
+
groupID,
|
|
86
|
+
keyID: bundle.keyID
|
|
87
|
+
});
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const issuer = keyVerified.issuer;
|
|
91
|
+
if (params.requireIssuer != null && issuer !== params.requireIssuer) {
|
|
92
|
+
logger?.warn(`${label} dropped: key op is signed by someone other than the frame issuer`, {
|
|
93
|
+
groupID,
|
|
94
|
+
keyID: bundle.keyID
|
|
95
|
+
});
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const keyParams = {
|
|
99
|
+
keyID: bundle.keyID,
|
|
100
|
+
ownerDID: issuer,
|
|
101
|
+
suite: bundle.suite,
|
|
102
|
+
version: bundle.keyVersion,
|
|
103
|
+
state: 'active'
|
|
104
|
+
};
|
|
105
|
+
if (!credentialOpMatchesPut(keyVerified.claim, {
|
|
106
|
+
kind: 'key',
|
|
107
|
+
params: keyParams
|
|
108
|
+
})) {
|
|
109
|
+
logger?.warn(`${label} dropped: key op does not speak for the key row`, {
|
|
110
|
+
groupID,
|
|
111
|
+
keyID: bundle.keyID
|
|
112
|
+
});
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
const existing = await store.getKey(bundle.keyID);
|
|
116
|
+
if (existing != null && normalizeDID(existing.owner_did) !== issuer) {
|
|
117
|
+
logger?.warn(`${label} dropped: key is owned by someone else here`, {
|
|
118
|
+
groupID,
|
|
119
|
+
keyID: bundle.keyID
|
|
120
|
+
});
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
// Bound the granter's stamps BEFORE the first write. The entry rows keep them
|
|
124
|
+
// verbatim and this device floors its clock to the greatest one it holds, so
|
|
125
|
+
// an unparseable or far-future stamp that lands is a clock this device can
|
|
126
|
+
// never catch up to — and every later local credential write would sort under
|
|
127
|
+
// it. The past direction stays unbounded: an offline write lands late.
|
|
128
|
+
//
|
|
129
|
+
// The whole bundle goes, never the offending entry alone: it is stored as a
|
|
130
|
+
// unit, so a partial apply would store a key whose contents disagree with the
|
|
131
|
+
// ops that authorized them.
|
|
132
|
+
const now = Date.now();
|
|
133
|
+
const bounded = [];
|
|
134
|
+
for (const entry of bundle.entries){
|
|
135
|
+
const stamp = HLC.tryParse(entry.hlc);
|
|
136
|
+
if (stamp == null || stamp.wallTime - now > params.maxDriftMS) {
|
|
137
|
+
logger?.warn(`${label} dropped: entry stamp is unusable`, {
|
|
138
|
+
groupID,
|
|
139
|
+
keyID: bundle.keyID,
|
|
140
|
+
entryID: entry.entryID,
|
|
141
|
+
hlc: entry.hlc
|
|
142
|
+
});
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
bounded.push({
|
|
146
|
+
entry,
|
|
147
|
+
stamp
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const wrappingParams = {
|
|
151
|
+
wrappingID: bundle.wrapping.wrappingID,
|
|
152
|
+
keyID: bundle.keyID,
|
|
153
|
+
keyVersion: bundle.keyVersion,
|
|
154
|
+
factors: bundle.wrapping.factors,
|
|
155
|
+
iv: fromB64U(bundle.wrapping.iv),
|
|
156
|
+
wrappedKey: fromB64U(bundle.wrapping.wrappedKey),
|
|
157
|
+
// Stored un-normalized, as the granter holds it: it is a key-resolution
|
|
158
|
+
// input, and a `did:peer:4` short form carries no agreement key.
|
|
159
|
+
recipientDID: bundle.wrapping.recipientDID
|
|
160
|
+
};
|
|
161
|
+
const entryPuts = bounded.map(({ entry, stamp })=>({
|
|
162
|
+
stamp,
|
|
163
|
+
token: entry.op,
|
|
164
|
+
params: {
|
|
165
|
+
entryID: entry.entryID,
|
|
166
|
+
keyID: bundle.keyID,
|
|
167
|
+
keyVersion: bundle.keyVersion,
|
|
168
|
+
iv: fromB64U(entry.iv),
|
|
169
|
+
ciphertext: fromB64U(entry.ciphertext),
|
|
170
|
+
// The GRANTER's stamp, never a local one: the entry op covers this field,
|
|
171
|
+
// so re-stamping would make the stored row disagree with the op that
|
|
172
|
+
// authenticated it.
|
|
173
|
+
hlc: entry.hlc
|
|
174
|
+
}
|
|
175
|
+
}));
|
|
176
|
+
/**
|
|
177
|
+
* Take the granter's op verbatim, or refuse it.
|
|
178
|
+
*
|
|
179
|
+
* The receiver stores an op it did not sign because the merkle leaf hashes the
|
|
180
|
+
* op: minting a local one would give the same logical row a different leaf on
|
|
181
|
+
* every device that received it, and the trees would never converge. `opHash`
|
|
182
|
+
* is recomputed here rather than carried, so a lying digest cannot enter that
|
|
183
|
+
* lane. `null` refuses the WHOLE bundle — the rows are stored as a set, so a
|
|
184
|
+
* partial apply would store a key whose contents disagree with what authorized
|
|
185
|
+
* them.
|
|
186
|
+
*/ const acceptOp = async (token, subject)=>{
|
|
187
|
+
const op = await verifyCredentialOp(token);
|
|
188
|
+
if (op == null || op.issuer !== issuer || !credentialOpMatchesPut(op.claim, subject)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
keyID: op.claim.keyID,
|
|
193
|
+
authorDID: op.issuer,
|
|
194
|
+
hlc: op.claim.hlc,
|
|
195
|
+
opJWT: token,
|
|
196
|
+
opHash: digestParts([
|
|
197
|
+
token
|
|
198
|
+
]),
|
|
199
|
+
// The branch this wrapping or entry signed. Absent on an op minted outside
|
|
200
|
+
// a rotation, which carries no branch yet.
|
|
201
|
+
branchID: op.claim.branchID ?? ''
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const keyOpHash = digestParts([
|
|
205
|
+
bundle.keyOp
|
|
206
|
+
]);
|
|
207
|
+
const keyOp = {
|
|
208
|
+
keyID: keyVerified.claim.keyID,
|
|
209
|
+
authorDID: issuer,
|
|
210
|
+
hlc: keyVerified.claim.hlc,
|
|
211
|
+
opJWT: bundle.keyOp,
|
|
212
|
+
opHash: keyOpHash,
|
|
213
|
+
// A key-op labels its own branch and never claims it; recomputed here from
|
|
214
|
+
// the op's own stamp and hash, which is what authenticates it.
|
|
215
|
+
branchID: `${keyVerified.claim.hlc}:${keyOpHash}`
|
|
216
|
+
};
|
|
217
|
+
const keyBranches = [];
|
|
218
|
+
for (const token of bundle.keyBranches){
|
|
219
|
+
const verified = await verifyCredentialOp(token);
|
|
220
|
+
if (verified == null || verified.issuer !== issuer) continue;
|
|
221
|
+
if (!credentialOpMatchesPut(verified.claim, {
|
|
222
|
+
kind: 'key',
|
|
223
|
+
params: keyParams
|
|
224
|
+
})) {
|
|
225
|
+
logger?.warn(`${label} dropped: key branch op does not speak for this key version`, {
|
|
226
|
+
groupID,
|
|
227
|
+
keyID: bundle.keyID
|
|
228
|
+
});
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
const stamp = HLC.tryParse(verified.claim.hlc);
|
|
232
|
+
if (stamp == null || stamp.wallTime - now > params.maxDriftMS) continue;
|
|
233
|
+
const opHash = digestParts([
|
|
234
|
+
token
|
|
235
|
+
]);
|
|
236
|
+
keyBranches.push({
|
|
237
|
+
keyID: bundle.keyID,
|
|
238
|
+
authorDID: verified.issuer,
|
|
239
|
+
hlc: verified.claim.hlc,
|
|
240
|
+
opJWT: token,
|
|
241
|
+
opHash,
|
|
242
|
+
branchID: `${verified.claim.hlc}:${opHash}`
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
const wrappingOp = await acceptOp(bundle.wrapping.op, {
|
|
246
|
+
kind: 'wrapping',
|
|
247
|
+
params: wrappingParams
|
|
248
|
+
});
|
|
249
|
+
const entryOps = await Promise.all(entryPuts.map(async (put)=>await acceptOp(put.token, {
|
|
250
|
+
kind: 'entry',
|
|
251
|
+
params: put.params
|
|
252
|
+
})));
|
|
253
|
+
if (wrappingOp == null || entryOps.some((op)=>op == null)) {
|
|
254
|
+
logger?.warn(`${label} dropped: an op does not speak for the row it travels with`, {
|
|
255
|
+
groupID,
|
|
256
|
+
keyID: bundle.keyID
|
|
257
|
+
});
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
const provenBranchIDs = new Set(keyBranches.map((branch)=>branch.branchID));
|
|
261
|
+
for (const rowOp of [
|
|
262
|
+
wrappingOp,
|
|
263
|
+
...entryOps
|
|
264
|
+
]){
|
|
265
|
+
if (rowOp != null && rowOp.branchID !== '' && !provenBranchIDs.has(rowOp.branchID)) {
|
|
266
|
+
logger?.warn(`${label} dropped: row branch has no included key provenance`, {
|
|
267
|
+
groupID,
|
|
268
|
+
keyID: bundle.keyID
|
|
269
|
+
});
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// The key and wrapping ops carry stamps no row keeps, so the entry gate above
|
|
274
|
+
// never saw them — and `kubun_credential_ops.hlc` is what the sync lane orders
|
|
275
|
+
// and buckets by. An entry op needs no second check: its claim stamp equals
|
|
276
|
+
// the entry row's, which `credentialOpMatchesPut` requires.
|
|
277
|
+
const opStamps = [];
|
|
278
|
+
for (const op of [
|
|
279
|
+
keyOp,
|
|
280
|
+
wrappingOp
|
|
281
|
+
]){
|
|
282
|
+
const stamp = HLC.tryParse(op.hlc);
|
|
283
|
+
if (stamp == null || stamp.wallTime - now > params.maxDriftMS) {
|
|
284
|
+
logger?.warn(`${label} dropped: an op stamp is unusable`, {
|
|
285
|
+
groupID,
|
|
286
|
+
keyID: bundle.keyID,
|
|
287
|
+
hlc: op.hlc
|
|
288
|
+
});
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
opStamps.push(stamp);
|
|
292
|
+
}
|
|
293
|
+
await store.putKey(keyParams, keyOp);
|
|
294
|
+
for (const branch of keyBranches){
|
|
295
|
+
await store.recordKeyBranch({
|
|
296
|
+
keyID: bundle.keyID,
|
|
297
|
+
keyVersion: bundle.keyVersion,
|
|
298
|
+
authorDID: branch.authorDID,
|
|
299
|
+
hlc: branch.hlc,
|
|
300
|
+
opJWT: branch.opJWT,
|
|
301
|
+
opHash: branch.opHash,
|
|
302
|
+
branchID: branch.branchID
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
await store.putWrapping(wrappingParams, wrappingOp);
|
|
306
|
+
for (const [index, put] of entryPuts.entries()){
|
|
307
|
+
await store.putEntry(put.params, entryOps[index]);
|
|
308
|
+
// Merged so a later local write sorts after what was received. Safe to
|
|
309
|
+
// receive unchecked: the gate above rejected the bundle unless every stamp
|
|
310
|
+
// parsed and sat inside the drift bound.
|
|
311
|
+
params.hlc?.receive(put.stamp);
|
|
312
|
+
}
|
|
313
|
+
for (const stamp of opStamps){
|
|
314
|
+
params.hlc?.receive(stamp);
|
|
315
|
+
}
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Apply a credential reconcile response: materialise the catch-up bundles a
|
|
320
|
+
* recipient missed, then GC the rows a tombstone revokes.
|
|
321
|
+
*
|
|
322
|
+
* The server's scoping is NOT trusted. Every row re-verifies its op signature
|
|
323
|
+
* and every wrapping re-checks it is addressed to this device before a write,
|
|
324
|
+
* exactly as an unsolicited grant would — a reconcile is a batch of the same
|
|
325
|
+
* self-authenticating rows, pulled instead of pushed.
|
|
326
|
+
*/ export async function applyCredentialReconcile(params) {
|
|
327
|
+
const { store, selfDID, logger } = params;
|
|
328
|
+
const maxDriftMS = params.maxDriftMS ?? DEFAULT_MAX_DRIFT_MS;
|
|
329
|
+
let applied = 0;
|
|
330
|
+
for (const bundle of params.bundles){
|
|
331
|
+
const wrote = await applyCredentialKeyRows({
|
|
332
|
+
store,
|
|
333
|
+
selfDID,
|
|
334
|
+
hlc: params.hlc,
|
|
335
|
+
maxDriftMS,
|
|
336
|
+
logger,
|
|
337
|
+
bundle,
|
|
338
|
+
label: 'credential:reconcile'
|
|
339
|
+
});
|
|
340
|
+
if (wrote) {
|
|
341
|
+
applied += 1;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
let tombstoned = 0;
|
|
345
|
+
for (const tombstone of params.tombstones){
|
|
346
|
+
if (await applyReconcileTombstone(store, tombstone, logger)) {
|
|
347
|
+
tombstoned += 1;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
applied,
|
|
352
|
+
tombstoned
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Delete one revoked subject, keying on the tombstone op it carries.
|
|
357
|
+
*
|
|
358
|
+
* The op self-authenticates the delete; the subject kind and id come from the
|
|
359
|
+
* VERIFIED claim, never the wire fields beside it. Only the key's owner may
|
|
360
|
+
* revoke, so the issuer is checked against the stored key's `owner_did` — a
|
|
361
|
+
* device holding no such key has nothing to GC and drops it.
|
|
362
|
+
*/ async function applyReconcileTombstone(store, tombstone, logger) {
|
|
363
|
+
const verified = await verifyCredentialOp(tombstone.opJWT);
|
|
364
|
+
if (verified == null || !verified.claim.deleted) {
|
|
365
|
+
logger?.warn('credential:reconcile tombstone dropped: not a verified tombstone', {
|
|
366
|
+
subjectID: tombstone.subjectID
|
|
367
|
+
});
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
const claim = verified.claim;
|
|
371
|
+
// The wire id is a hint; the signed one decides which row is deleted.
|
|
372
|
+
if (claim.subjectID !== tombstone.subjectID) {
|
|
373
|
+
logger?.warn('credential:reconcile tombstone dropped: subject id does not match the op', {
|
|
374
|
+
subjectID: tombstone.subjectID
|
|
375
|
+
});
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
const key = await store.getKey(claim.keyID);
|
|
379
|
+
if (key == null || normalizeDID(key.owner_did) !== verified.issuer) {
|
|
380
|
+
logger?.warn('credential:reconcile tombstone dropped: not authored by the key owner', {
|
|
381
|
+
keyID: claim.keyID,
|
|
382
|
+
subjectID: claim.subjectID
|
|
383
|
+
});
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
const op = {
|
|
387
|
+
keyID: claim.keyID,
|
|
388
|
+
authorDID: verified.issuer,
|
|
389
|
+
hlc: claim.hlc,
|
|
390
|
+
opJWT: tombstone.opJWT,
|
|
391
|
+
opHash: digestParts([
|
|
392
|
+
tombstone.opJWT
|
|
393
|
+
]),
|
|
394
|
+
// A tombstone carries no branch label yet — resolving a tombstone onto the
|
|
395
|
+
// branch it retires is a later slice.
|
|
396
|
+
branchID: claim.branchID ?? ''
|
|
397
|
+
};
|
|
398
|
+
switch(claim.subjectKind){
|
|
399
|
+
case 'key':
|
|
400
|
+
await store.deleteKey(claim.subjectID, op);
|
|
401
|
+
break;
|
|
402
|
+
case 'wrapping':
|
|
403
|
+
await store.deleteWrapping(claim.subjectID, op);
|
|
404
|
+
break;
|
|
405
|
+
case 'entry':
|
|
406
|
+
await store.deleteEntry(claim.subjectID, op);
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type OwnIdentity } from '@kokuin/token';
|
|
2
|
+
import { credentialEntriesDigest, credentialWrappingDigest } from '@kubun/credential';
|
|
3
|
+
/**
|
|
4
|
+
* Re-exported where the grant lane reads them. They live in `@kubun/credential`
|
|
5
|
+
* because the credential manager signs the same digests over its own rows, and
|
|
6
|
+
* an edge back up to this package would be a cycle.
|
|
7
|
+
*/
|
|
8
|
+
export { credentialEntriesDigest, credentialWrappingDigest };
|
|
9
|
+
/** Order-independent authentication for the complete set of branch-op tokens. */
|
|
10
|
+
export declare function credentialKeyBranchesDigest(tokens: ReadonlyArray<string>): string;
|
|
11
|
+
/**
|
|
12
|
+
* What a granter signs: identifiers and digests, never the ciphertext itself.
|
|
13
|
+
*
|
|
14
|
+
* Mirroring the whole frame the way `access-default:set` does would double a
|
|
15
|
+
* vault-sized grant on the wire. Leaving the entries out of the signature
|
|
16
|
+
* altogether was the other option and is worse than it looks: substituted
|
|
17
|
+
* ciphertext IS caught, but by the AEAD tag at read time, and it surfaces as
|
|
18
|
+
* `CredentialTampered` — a relay swap reported to the operator as an attack on
|
|
19
|
+
* their own store.
|
|
20
|
+
*/
|
|
21
|
+
export type CredentialKeyGrantClaim = {
|
|
22
|
+
keyID: string;
|
|
23
|
+
keyVersion: number;
|
|
24
|
+
suite: number;
|
|
25
|
+
/** The wrapping's recipient, in the same un-normalized form the frame carries. */
|
|
26
|
+
recipientDID: string;
|
|
27
|
+
wrappingDigest: string;
|
|
28
|
+
entriesDigest: string;
|
|
29
|
+
keyBranchesDigest: string;
|
|
30
|
+
};
|
|
31
|
+
/** `issuer` is the normalized verified `iss` — the only owner claim a receiver trusts. */
|
|
32
|
+
export type VerifiedCredentialKeyGrant = {
|
|
33
|
+
issuer: string;
|
|
34
|
+
claim: CredentialKeyGrantClaim;
|
|
35
|
+
};
|
|
36
|
+
export declare function signCredentialKeyGrant(identity: OwnIdentity, claim: CredentialKeyGrantClaim): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* Verify a grant token and extract its claim. Returns `null` (never throws) when
|
|
39
|
+
* the token is unparseable, unsigned (`alg: 'none'`), or structurally malformed —
|
|
40
|
+
* an attacker cannot forge an `iss` this way.
|
|
41
|
+
*/
|
|
42
|
+
export declare function verifyCredentialKeyGrant(token: string): Promise<VerifiedCredentialKeyGrant | null>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { isVerifiedToken, normalizeDID, stringifyToken, verifyToken } from '@kokuin/token';
|
|
2
|
+
import { credentialEntriesDigest, credentialWrappingDigest, digestParts } from '@kubun/credential';
|
|
3
|
+
/**
|
|
4
|
+
* Re-exported where the grant lane reads them. They live in `@kubun/credential`
|
|
5
|
+
* because the credential manager signs the same digests over its own rows, and
|
|
6
|
+
* an edge back up to this package would be a cycle.
|
|
7
|
+
*/ export { credentialEntriesDigest, credentialWrappingDigest };
|
|
8
|
+
/** Order-independent authentication for the complete set of branch-op tokens. */ export function credentialKeyBranchesDigest(tokens) {
|
|
9
|
+
return digestParts([
|
|
10
|
+
...tokens
|
|
11
|
+
].sort());
|
|
12
|
+
}
|
|
13
|
+
export async function signCredentialKeyGrant(identity, claim) {
|
|
14
|
+
const signed = await identity.signToken({
|
|
15
|
+
...claim
|
|
16
|
+
}, {
|
|
17
|
+
embedLongForm: true
|
|
18
|
+
});
|
|
19
|
+
return stringifyToken(signed);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Verify a grant token and extract its claim. Returns `null` (never throws) when
|
|
23
|
+
* the token is unparseable, unsigned (`alg: 'none'`), or structurally malformed —
|
|
24
|
+
* an attacker cannot forge an `iss` this way.
|
|
25
|
+
*/ export async function verifyCredentialKeyGrant(token) {
|
|
26
|
+
let verified;
|
|
27
|
+
try {
|
|
28
|
+
verified = await verifyToken(token);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (!isVerifiedToken(verified)) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const { iss, keyID, keyVersion, suite, recipientDID, wrappingDigest, entriesDigest, keyBranchesDigest } = verified.payload;
|
|
36
|
+
if (typeof keyID !== 'string' || typeof keyVersion !== 'number' || typeof suite !== 'number' || typeof recipientDID !== 'string' || typeof wrappingDigest !== 'string' || typeof entriesDigest !== 'string' || typeof keyBranchesDigest !== 'string') {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
issuer: normalizeDID(iss),
|
|
41
|
+
claim: {
|
|
42
|
+
keyID,
|
|
43
|
+
keyVersion,
|
|
44
|
+
suite,
|
|
45
|
+
recipientDID,
|
|
46
|
+
wrappingDigest,
|
|
47
|
+
entriesDigest,
|
|
48
|
+
keyBranchesDigest
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type OwnIdentity } from '@kokuin/token';
|
|
2
|
+
import type { CredentialAuthority, CredentialManager } from '@kubun/credential';
|
|
3
|
+
import type { StoreProvider } from '@kubun/db';
|
|
4
|
+
import type { CredentialStoreAPI } from '@kubun/store-credential';
|
|
5
|
+
import type { CredentialKeyBundle, GroupBroadcastMessage } from './broadcast-message.js';
|
|
6
|
+
import type { GroupHandleRegistry } from './group-handle-registry.js';
|
|
7
|
+
/**
|
|
8
|
+
* The recipient of a grant holds no leaf in the group, so there is no
|
|
9
|
+
* authenticated document to wrap to.
|
|
10
|
+
*
|
|
11
|
+
* Named rather than left to fail at wrap time: an unresolved recipient reaches
|
|
12
|
+
* `deriveSharedSecret` as whatever string the caller passed, and comes back as a
|
|
13
|
+
* DID-shaped complaint about a form — which reads as a bad argument when what
|
|
14
|
+
* happened is that the group has no such member.
|
|
15
|
+
*/
|
|
16
|
+
export declare class CredentialRecipientNotInGroup extends Error {
|
|
17
|
+
#private;
|
|
18
|
+
constructor(groupID: string, recipientDID: string);
|
|
19
|
+
get groupID(): string;
|
|
20
|
+
get recipientDID(): string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The rows a grant wrote are gone by the time the frame is assembled.
|
|
24
|
+
*
|
|
25
|
+
* Reachable: a rotation landing between the grant's version read and this read
|
|
26
|
+
* back leaves the new wrapping below the current version, so it is not among the
|
|
27
|
+
* ones read here. Refusing is what stops a frame going out that names a version
|
|
28
|
+
* with no way in.
|
|
29
|
+
*/
|
|
30
|
+
export declare class CredentialGrantNotReadable extends Error {
|
|
31
|
+
#private;
|
|
32
|
+
constructor(keyID: string, detail: string);
|
|
33
|
+
get keyID(): string;
|
|
34
|
+
}
|
|
35
|
+
export type GrantCredentialKeyParams = {
|
|
36
|
+
registry: GroupHandleRegistry;
|
|
37
|
+
/** Bound to the granting device's own credential store. */
|
|
38
|
+
credentials: CredentialManager;
|
|
39
|
+
/**
|
|
40
|
+
* Signs the frame. The same identity the manager is built on: `grant` needs
|
|
41
|
+
* `administer`, which today means the key's owner, so the signer and the
|
|
42
|
+
* `owner_did` a receiver mints are the same principal.
|
|
43
|
+
*/
|
|
44
|
+
identity: OwnIdentity;
|
|
45
|
+
/**
|
|
46
|
+
* The very store that manager writes through. The frame is read back from it,
|
|
47
|
+
* so a different handle on the same data — one outside the caller's
|
|
48
|
+
* transaction, say — would assemble from rows the grant did not write.
|
|
49
|
+
*/
|
|
50
|
+
store: CredentialStoreAPI;
|
|
51
|
+
groupID: string;
|
|
52
|
+
keyID: string;
|
|
53
|
+
/** Either form: the roster answers on the normalized one. */
|
|
54
|
+
recipientDID: string;
|
|
55
|
+
/** Fire-and-forget fan-out. Called after the grant's write commits. */
|
|
56
|
+
scheduleBroadcast: (groupID: string, message: GroupBroadcastMessage) => void;
|
|
57
|
+
/** The request's provider, so the handle restore reads the caller's connection. */
|
|
58
|
+
stores: StoreProvider;
|
|
59
|
+
/**
|
|
60
|
+
* Per-request authority forwarded to `credentials.grant`, overriding the
|
|
61
|
+
* manager's constructed default. Not yet fed one: the group-grant caller that
|
|
62
|
+
* will resolve a request authority is a follow-up.
|
|
63
|
+
*/
|
|
64
|
+
authority?: CredentialAuthority;
|
|
65
|
+
};
|
|
66
|
+
export type GrantCredentialKeyResult = {
|
|
67
|
+
version: number;
|
|
68
|
+
/**
|
|
69
|
+
* False when the recipient already held a wrapping. The frame goes out either
|
|
70
|
+
* way: a repeat grant is how an undelivered one is retried.
|
|
71
|
+
*/
|
|
72
|
+
created: boolean;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Grant a credential key to a co-member, resolving what to wrap to from the MLS
|
|
76
|
+
* roster, and broadcast the frame that carries it there.
|
|
77
|
+
*
|
|
78
|
+
* The leaf credential is the authenticated original — signed, and immutable for
|
|
79
|
+
* as long as the leaf exists — so it is the only copy of a member's resolvable
|
|
80
|
+
* DID that cannot have been rewritten. The membership row cannot answer: its
|
|
81
|
+
* `member_did` is normalized, and for `did:peer:4` that is the short form, which
|
|
82
|
+
* carries no document and so no agreement key.
|
|
83
|
+
*
|
|
84
|
+
* The write happens OUTSIDE the handle lock. Resolving is a pure read of the
|
|
85
|
+
* ratchet tree, while the grant is a store call, and holding a group's mutex
|
|
86
|
+
* across one is the deadlock this repo has already paid for once.
|
|
87
|
+
*/
|
|
88
|
+
export declare function grantCredentialKeyToMember(params: GrantCredentialKeyParams): Promise<GrantCredentialKeyResult>;
|
|
89
|
+
/**
|
|
90
|
+
* Read one key's row bytes back for a recipient, as a grant carries them minus
|
|
91
|
+
* the `auth` envelope. Shared by the grant path and the credential reconcile
|
|
92
|
+
* lane, which both have to hand a receiver exactly the granter's stored rows.
|
|
93
|
+
*
|
|
94
|
+
* Throws {@link CredentialGrantNotReadable} on a granter-side gap — a missing
|
|
95
|
+
* key row, or a wrapping a rotation removed between the version read and this
|
|
96
|
+
* read back — so a caller never ships a bundle that names bytes with no way in.
|
|
97
|
+
*/
|
|
98
|
+
export declare function readCredentialKeyBundle(store: CredentialStoreAPI, keyID: string, recipientWrappableDID: string): Promise<CredentialKeyBundle>;
|