@aithos/sdk 0.1.0-alpha.44 → 0.1.0-alpha.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/auth-api.d.ts +81 -0
- package/dist/src/auth-api.js +80 -0
- package/dist/src/auth.d.ts +84 -0
- package/dist/src/auth.js +133 -1
- package/dist/src/data.d.ts +184 -1
- package/dist/src/data.js +377 -29
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +1 -1
- package/dist/src/internal/envelope.d.ts +16 -0
- package/dist/src/internal/envelope.js +6 -0
- package/dist/src/mandates.d.ts +35 -1
- package/dist/src/mandates.js +45 -3
- package/package.json +1 -1
package/dist/src/data.js
CHANGED
|
@@ -38,6 +38,7 @@ import { hkdf } from "@noble/hashes/hkdf.js";
|
|
|
38
38
|
import { sha256, sha512 } from "@noble/hashes/sha2.js";
|
|
39
39
|
import { XChaCha20Poly1305 } from "@stablelib/xchacha20poly1305";
|
|
40
40
|
import * as ed from "@noble/ed25519";
|
|
41
|
+
import { multibaseToEd25519PublicKey, edPubToX25519Pub, } from "@aithos/protocol-client";
|
|
41
42
|
import { contactsV1 } from "./data-schema-contacts-v1.js";
|
|
42
43
|
import { signOwnerEnvelope } from "./internal/envelope.js";
|
|
43
44
|
// noble/ed25519 v2 needs sha512 wired in for sync sign/verify
|
|
@@ -57,15 +58,95 @@ const SCHEMAS = new Map([
|
|
|
57
58
|
export function createDataClient(args) {
|
|
58
59
|
return new DataClientImpl(args);
|
|
59
60
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Build a read-only data client that reads a subject's collections under
|
|
63
|
+
* a mandate (delegate path). The returned {@link ReadonlyDataClient}
|
|
64
|
+
* signs every request as the delegate (bare-multibase verificationMethod
|
|
65
|
+
* + the mandate attached to the envelope), and decrypts records using the
|
|
66
|
+
* CMK the owner re-wrapped for this delegate via
|
|
67
|
+
* {@link DataClient.authorizeDelegate}.
|
|
68
|
+
*
|
|
69
|
+
* Writes are not available on the returned type and throw `-32042` if
|
|
70
|
+
* forced.
|
|
71
|
+
*/
|
|
72
|
+
export function createDelegateDataClient(args) {
|
|
73
|
+
const granteePubMb = args.granteePubkeyMultibase ??
|
|
74
|
+
args.mandate.grantee?.pubkey;
|
|
75
|
+
if (!granteePubMb) {
|
|
76
|
+
throw new Error("createDelegateDataClient: mandate.grantee.pubkey is missing; pass granteePubkeyMultibase explicitly");
|
|
77
|
+
}
|
|
78
|
+
return new DataClientImpl({
|
|
79
|
+
pdsUrl: args.pdsUrl,
|
|
80
|
+
did: args.subjectDid,
|
|
81
|
+
// In delegate mode the seed is used only to derive the X25519 key that
|
|
82
|
+
// unwraps the re-wrapped CMK; envelope signing goes through the
|
|
83
|
+
// delegate path (buildSignedEnvelope), never signOwnerEnvelope.
|
|
84
|
+
sphereSeed: args.delegateSeed,
|
|
85
|
+
verificationMethod: granteePubMb,
|
|
86
|
+
...(args.schemas ? { schemas: args.schemas } : {}),
|
|
87
|
+
...(args.fetch ? { fetch: args.fetch } : {}),
|
|
88
|
+
delegate: {
|
|
89
|
+
delegateSeed: args.delegateSeed,
|
|
90
|
+
granteePubkeyMultibase: granteePubMb,
|
|
91
|
+
mandate: args.mandate,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Build an **append-only** data client from a `data.<collection>.append`
|
|
97
|
+
* mandate. The returned {@link AppendOnlyDataClient} can ONLY `insert`: it
|
|
98
|
+
* seals each record's DEK to the owner's public key (never the CMK), so it
|
|
99
|
+
* holds no read capability — it cannot decrypt anything in the collection,
|
|
100
|
+
* not even its own deposit. The PDS additionally enforces the append scope
|
|
101
|
+
* (insert allowed; get/list/update/delete refused).
|
|
102
|
+
*/
|
|
103
|
+
export function createAppendDataClient(args) {
|
|
104
|
+
const granteePubMb = args.granteePubkeyMultibase ??
|
|
105
|
+
args.mandate.grantee?.pubkey;
|
|
106
|
+
if (!granteePubMb) {
|
|
107
|
+
throw new Error("createAppendDataClient: mandate.grantee.pubkey is missing; pass granteePubkeyMultibase explicitly");
|
|
108
|
+
}
|
|
109
|
+
const ownerKexPublicKey = edPubToX25519Pub(multibaseToEd25519PublicKey(args.ownerDataPubkeyMultibase));
|
|
110
|
+
const impl = new DataClientImpl({
|
|
111
|
+
pdsUrl: args.pdsUrl,
|
|
112
|
+
did: args.subjectDid,
|
|
113
|
+
// In deposit mode the seed signs envelopes; it is NEVER used to unwrap a
|
|
114
|
+
// CMK (the deposit client has none).
|
|
115
|
+
sphereSeed: args.delegateSeed,
|
|
116
|
+
verificationMethod: granteePubMb,
|
|
117
|
+
schemas: [args.schema, ...(args.schemas ?? [])],
|
|
118
|
+
...(args.fetch ? { fetch: args.fetch } : {}),
|
|
119
|
+
deposit: {
|
|
120
|
+
delegateSeed: args.delegateSeed,
|
|
121
|
+
granteePubkeyMultibase: granteePubMb,
|
|
122
|
+
mandate: args.mandate,
|
|
123
|
+
ownerKexPublicKey,
|
|
124
|
+
ownerKexDidUrl: `${args.subjectDid}#data-kex`,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
const defaultSchema = args.schema;
|
|
128
|
+
return {
|
|
129
|
+
collection(name) {
|
|
130
|
+
const state = impl._depositCollectionState(name, defaultSchema);
|
|
131
|
+
return {
|
|
132
|
+
name,
|
|
133
|
+
insert: (record) => impl._insertDeposit(state, record),
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
reset: () => impl.reset(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
63
139
|
class DataClientImpl {
|
|
64
140
|
#pdsUrl;
|
|
65
141
|
#did;
|
|
66
142
|
#seed;
|
|
67
143
|
#vm;
|
|
68
144
|
#fetch;
|
|
145
|
+
/** Delegate session, or undefined for the owner path. */
|
|
146
|
+
#delegate;
|
|
147
|
+
/** Deposit (append-only) session, or undefined. Mutually exclusive with
|
|
148
|
+
* {@link DataClientImpl.#delegate}. */
|
|
149
|
+
#deposit;
|
|
69
150
|
/**
|
|
70
151
|
* Per-client schema overrides, populated from `args.schemas` at
|
|
71
152
|
* construction. Looked up BEFORE the bundled SCHEMAS map (so an app
|
|
@@ -82,8 +163,24 @@ class DataClientImpl {
|
|
|
82
163
|
this.#seed = args.sphereSeed;
|
|
83
164
|
this.#vm = args.verificationMethod;
|
|
84
165
|
this.#fetch = args.fetch ?? globalThis.fetch.bind(globalThis);
|
|
166
|
+
if (args.delegate)
|
|
167
|
+
this.#delegate = args.delegate;
|
|
168
|
+
if (args.deposit)
|
|
169
|
+
this.#deposit = args.deposit;
|
|
85
170
|
this.#localSchemas = new Map((args.schemas ?? []).map((s) => [s.schema, s]));
|
|
86
171
|
}
|
|
172
|
+
/** Throw a read-only error when a mutating verb is called on a delegate
|
|
173
|
+
* client. The PDS rejects these server-side too; this is the fast,
|
|
174
|
+
* local guard with a precise message. */
|
|
175
|
+
#assertOwner(op) {
|
|
176
|
+
if (this.#delegate) {
|
|
177
|
+
const e = new Error(`sdk.data: "${op}" is not permitted for a delegate client (read-only mandate). ` +
|
|
178
|
+
`A data.<collection>.read mandate grants get/list only; writes require the owner ` +
|
|
179
|
+
`or a data.<collection>.write mandate (not yet supported on the delegate path).`);
|
|
180
|
+
e.code = -32042;
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
87
184
|
/**
|
|
88
185
|
* Resolve a schema id to its lite definition. App-supplied schemas
|
|
89
186
|
* (via `createDataClient({ schemas })`) take precedence over the
|
|
@@ -95,7 +192,45 @@ class DataClientImpl {
|
|
|
95
192
|
collection(name) {
|
|
96
193
|
return new DataCollectionImpl(this, name);
|
|
97
194
|
}
|
|
195
|
+
async authorizeDelegate(args) {
|
|
196
|
+
this.#assertOwner("authorizeDelegate");
|
|
197
|
+
const granteePubMb = args.mandate.grantee?.pubkey;
|
|
198
|
+
if (!granteePubMb) {
|
|
199
|
+
throw new Error("sdk.data.authorizeDelegate: mandate.grantee.pubkey is required (data read mandates bind to a grantee key).");
|
|
200
|
+
}
|
|
201
|
+
// Ensure we hold the collection's CMK (owner unwrap path).
|
|
202
|
+
const state = await this._ensureCollection(args.collectionName);
|
|
203
|
+
const cmk = this.#cmkCache.get(args.collectionName);
|
|
204
|
+
if (!cmk) {
|
|
205
|
+
throw new Error(`sdk.data.authorizeDelegate: CMK for "${args.collectionName}" not loaded`);
|
|
206
|
+
}
|
|
207
|
+
// Derive the grantee's X25519 wrap-recipient key from its Ed25519
|
|
208
|
+
// public key (the same birational map the owner uses for its own key).
|
|
209
|
+
const granteeEdPub = multibaseToEd25519PublicKey(granteePubMb);
|
|
210
|
+
const granteeX25519Pub = edPubToX25519Pub(granteeEdPub);
|
|
211
|
+
const recipientDidUrl = delegateRecipientDidUrl(granteePubMb);
|
|
212
|
+
const wrap = this.#wrapCmkForRecipient({
|
|
213
|
+
cmk,
|
|
214
|
+
recipientPublicKey: granteeX25519Pub,
|
|
215
|
+
recipientDidUrl,
|
|
216
|
+
collectionUrn: state.urn,
|
|
217
|
+
});
|
|
218
|
+
await this.#call("/mcp/primitives/write", "aithos.data.authorize_app", {
|
|
219
|
+
collection_urn: state.urn,
|
|
220
|
+
mandate: args.mandate,
|
|
221
|
+
wrap,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async revokeDelegate(args) {
|
|
225
|
+
this.#assertOwner("revokeDelegate");
|
|
226
|
+
await this.#call("/mcp/primitives/write", "aithos.data.revoke_app", {
|
|
227
|
+
collection_urn: this.#collectionUrn(args.collectionName),
|
|
228
|
+
mandate_id: args.mandateId,
|
|
229
|
+
...(args.reason ? { revocation: { reason: args.reason } } : {}),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
98
232
|
async createCollection(args) {
|
|
233
|
+
this.#assertOwner("createCollection");
|
|
99
234
|
const cmk = randomBytes32();
|
|
100
235
|
const recipientDidUrl = `${this.#did}#data-kex`;
|
|
101
236
|
const collectionUrn = this.#collectionUrn(args.name);
|
|
@@ -124,6 +259,20 @@ class DataClientImpl {
|
|
|
124
259
|
// CMK is retained in cache, only zero the local var if we didn't cache.
|
|
125
260
|
}
|
|
126
261
|
}
|
|
262
|
+
async ensureCollection(args) {
|
|
263
|
+
this.#assertOwner("ensureCollection");
|
|
264
|
+
try {
|
|
265
|
+
await this.createCollection(args);
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
// -32073 AITHOS_DATA_COLLECTION_EXISTS → already created (possibly by a
|
|
269
|
+
// concurrent caller). That's the success case for get-or-create. Any
|
|
270
|
+
// other error propagates.
|
|
271
|
+
if (e.code === -32073)
|
|
272
|
+
return;
|
|
273
|
+
throw e;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
127
276
|
async listCollections() {
|
|
128
277
|
const r = await this.#call("/mcp/primitives/read", "aithos.data.list_collections", {
|
|
129
278
|
subject_did: this.#did,
|
|
@@ -146,6 +295,7 @@ class DataClientImpl {
|
|
|
146
295
|
return this.#call("/mcp/primitives/read", "aithos.data.list_gamma_entries", params);
|
|
147
296
|
}
|
|
148
297
|
async registerSchema(schemaDoc) {
|
|
298
|
+
this.#assertOwner("registerSchema");
|
|
149
299
|
if (schemaDoc === null || typeof schemaDoc !== "object" || Array.isArray(schemaDoc)) {
|
|
150
300
|
throw new Error("sdk.data.registerSchema: schemaDoc must be a JSON object");
|
|
151
301
|
}
|
|
@@ -210,16 +360,27 @@ class DataClientImpl {
|
|
|
210
360
|
err.code = -32020;
|
|
211
361
|
throw err;
|
|
212
362
|
}
|
|
213
|
-
// Look up our wrap and unwrap the CMK
|
|
214
|
-
|
|
363
|
+
// Look up our wrap and unwrap the CMK. Owner: the wrap addressed to
|
|
364
|
+
// `${did}#data-kex`, unwrapped with the owner sphere seed. Delegate:
|
|
365
|
+
// the wrap the owner re-wrapped for this grantee
|
|
366
|
+
// (`did:key:${granteePubkeyMultibase}#data-kex`), unwrapped with the
|
|
367
|
+
// delegate's own X25519 key (derived from its grantee seed).
|
|
368
|
+
const ourRecipient = this.#delegate
|
|
369
|
+
? delegateRecipientDidUrl(this.#delegate.granteePubkeyMultibase)
|
|
370
|
+
: `${this.#did}#data-kex`;
|
|
215
371
|
const wrap = meta.cmk_envelope.wraps.find((w) => w.recipient === ourRecipient);
|
|
216
372
|
if (!wrap) {
|
|
217
|
-
throw new Error(
|
|
373
|
+
throw new Error(this.#delegate
|
|
374
|
+
? `sdk.data: no CMK wrap for ${ourRecipient} in collection "${name}". ` +
|
|
375
|
+
`The owner has not authorized this delegate on the collection yet — ` +
|
|
376
|
+
`ask them to call sdk.data...authorizeDelegate({ collectionName, mandate }).`
|
|
377
|
+
: `sdk.data: no CMK wrap for ${ourRecipient} in collection ${name}. The collection was either created with a different recipient, or this client is not the owner.`);
|
|
218
378
|
}
|
|
379
|
+
const unwrapSeed = this.#delegate ? this.#delegate.delegateSeed : this.#seed;
|
|
219
380
|
const cmk = this.#unwrapCmk({
|
|
220
381
|
wrap,
|
|
221
382
|
collectionUrn: meta.urn,
|
|
222
|
-
privateKey: ed25519SeedToX25519PrivateKey(
|
|
383
|
+
privateKey: ed25519SeedToX25519PrivateKey(unwrapSeed),
|
|
223
384
|
});
|
|
224
385
|
this.#cmkCache.set(name, cmk);
|
|
225
386
|
const schema = this.#resolveSchema(meta.schema);
|
|
@@ -234,6 +395,7 @@ class DataClientImpl {
|
|
|
234
395
|
return state;
|
|
235
396
|
}
|
|
236
397
|
async _insert(state, record) {
|
|
398
|
+
this.#assertOwner("insert");
|
|
237
399
|
const cmk = this.#cmkCache.get(state.name);
|
|
238
400
|
if (!cmk)
|
|
239
401
|
throw new Error("CMK not loaded");
|
|
@@ -281,13 +443,28 @@ class DataClientImpl {
|
|
|
281
443
|
if (opts.cursor)
|
|
282
444
|
params.cursor = opts.cursor;
|
|
283
445
|
const r = (await this.#call("/mcp/primitives/read", "aithos.data.list_records", params));
|
|
284
|
-
|
|
446
|
+
// A read/write delegate (CMK-holder) cannot decrypt append-only deposits
|
|
447
|
+
// (sealed to the owner key). Skip them rather than crash the whole list —
|
|
448
|
+
// the owner reading the same collection decrypts everything. -32044 is the
|
|
449
|
+
// client-side "deposit unreadable by this session" marker.
|
|
450
|
+
const items = [];
|
|
451
|
+
for (const it of r.items) {
|
|
452
|
+
try {
|
|
453
|
+
items.push(this.#decryptRecord(state, it));
|
|
454
|
+
}
|
|
455
|
+
catch (e) {
|
|
456
|
+
if (e.code === -32044)
|
|
457
|
+
continue;
|
|
458
|
+
throw e;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
285
461
|
return {
|
|
286
462
|
items,
|
|
287
463
|
...(r.next_cursor ? { nextCursor: r.next_cursor } : {}),
|
|
288
464
|
};
|
|
289
465
|
}
|
|
290
466
|
async _update(state, recordId, record) {
|
|
467
|
+
this.#assertOwner("update");
|
|
291
468
|
const cmk = this.#cmkCache.get(state.name);
|
|
292
469
|
if (!cmk)
|
|
293
470
|
throw new Error("CMK not loaded");
|
|
@@ -306,6 +483,7 @@ class DataClientImpl {
|
|
|
306
483
|
});
|
|
307
484
|
}
|
|
308
485
|
async _delete(state, recordId) {
|
|
486
|
+
this.#assertOwner("delete");
|
|
309
487
|
await this.#call("/mcp/primitives/write", "aithos.data.delete_record", {
|
|
310
488
|
collection_urn: state.urn,
|
|
311
489
|
record_id: recordId,
|
|
@@ -314,14 +492,35 @@ class DataClientImpl {
|
|
|
314
492
|
/* -- JSON-RPC dispatch -- */
|
|
315
493
|
async #call(path, method, params) {
|
|
316
494
|
const aud = `${this.#pdsUrl}${path}`;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
495
|
+
// Both paths use the SAME §11.2 envelope scheme (the data PDS verifies
|
|
496
|
+
// with @aithos/protocol-core, which canonicalizes the full envelope
|
|
497
|
+
// INCLUDING `proof` with proofValue=""). Delegate path: sign with the
|
|
498
|
+
// grantee key, bare-multibase verificationMethod, and attach the mandate
|
|
499
|
+
// so the PDS resolves the delegation and enforces its scopes.
|
|
500
|
+
// A mandate-bearing session (read-delegate OR append-deposit) signs as
|
|
501
|
+
// the grantee with the bare-multibase verificationMethod and attaches the
|
|
502
|
+
// mandate so the PDS resolves the delegation and enforces its scopes.
|
|
503
|
+
const mandateSession = this.#delegate ?? this.#deposit;
|
|
504
|
+
const envelope = mandateSession
|
|
505
|
+
? await signOwnerEnvelope({
|
|
506
|
+
iss: this.#did, // the SUBJECT DID (mandate issuer), not the delegate
|
|
507
|
+
aud,
|
|
508
|
+
method,
|
|
509
|
+
params,
|
|
510
|
+
verificationMethod: mandateSession.granteePubkeyMultibase,
|
|
511
|
+
signer: {
|
|
512
|
+
sign: async (msg) => ed.sign(msg, mandateSession.delegateSeed),
|
|
513
|
+
},
|
|
514
|
+
mandate: mandateSession.mandate,
|
|
515
|
+
})
|
|
516
|
+
: await signOwnerEnvelope({
|
|
517
|
+
iss: this.#did,
|
|
518
|
+
aud,
|
|
519
|
+
method,
|
|
520
|
+
params,
|
|
521
|
+
verificationMethod: this.#vm,
|
|
522
|
+
signer: { sign: async (msg) => ed.sign(msg, this.#seed) },
|
|
523
|
+
});
|
|
325
524
|
const body = {
|
|
326
525
|
jsonrpc: "2.0",
|
|
327
526
|
id: makeUlid(),
|
|
@@ -406,24 +605,95 @@ class DataClientImpl {
|
|
|
406
605
|
dek.fill(0);
|
|
407
606
|
}
|
|
408
607
|
}
|
|
608
|
+
/**
|
|
609
|
+
* Seal a record's payload for the append-only deposit path: encrypt under a
|
|
610
|
+
* fresh DEK, then seal that DEK to the OWNER's X25519 public key (never the
|
|
611
|
+
* CMK). Mirrors `@aithos/data-crypto` `wrapDEKForRecipient` byte-for-byte so
|
|
612
|
+
* the owner (SDK or CLI) can unwrap it. The depositor keeps no key material.
|
|
613
|
+
*/
|
|
614
|
+
#encryptPayloadForOwner(args) {
|
|
615
|
+
const dek = randomBytes32();
|
|
616
|
+
try {
|
|
617
|
+
const plaintext = new TextEncoder().encode(jcsCanonicalize(args.payload));
|
|
618
|
+
const nonce = randomBytes24();
|
|
619
|
+
const aad = aadRecord(this.#did, args.collectionName, args.recordId);
|
|
620
|
+
const ciphertext = new XChaCha20Poly1305(dek).seal(nonce, plaintext, aad);
|
|
621
|
+
// Seal the DEK to the owner's X25519 key (ECIES, fresh ephemeral).
|
|
622
|
+
const ephSk = x25519.utils.randomSecretKey();
|
|
623
|
+
const ephPk = x25519.getPublicKey(ephSk);
|
|
624
|
+
const shared = x25519.getSharedSecret(ephSk, args.ownerKexPublicKey);
|
|
625
|
+
const wrapKey = hkdf(sha256, shared, DEPOSIT_WRAP_SALT, utf8(args.ownerKexDidUrl), 32);
|
|
626
|
+
const wrapNonce = randomBytes24();
|
|
627
|
+
const wrapAad = aadDepositWrap(this.#did, args.collectionName, args.recordId, args.ownerKexDidUrl);
|
|
628
|
+
const wrappedKey = new XChaCha20Poly1305(wrapKey).seal(wrapNonce, dek, wrapAad);
|
|
629
|
+
wrapKey.fill(0);
|
|
630
|
+
shared.fill(0);
|
|
631
|
+
return {
|
|
632
|
+
alg: "xchacha20poly1305-ietf",
|
|
633
|
+
nonce: base64Std(nonce),
|
|
634
|
+
ciphertext: base64Std(ciphertext),
|
|
635
|
+
dek_wrapped_for_owner: {
|
|
636
|
+
recipient: args.ownerKexDidUrl,
|
|
637
|
+
alg: "x25519-hkdf-sha256-aead",
|
|
638
|
+
ephemeral_public: base64Std(ephPk),
|
|
639
|
+
wrap_nonce: base64Std(wrapNonce),
|
|
640
|
+
wrapped_key: base64Std(wrappedKey),
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
finally {
|
|
645
|
+
dek.fill(0);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
409
648
|
#decryptRecord(state, raw) {
|
|
410
649
|
if (raw.deleted) {
|
|
411
650
|
// Soft-deleted record — payload was cleared.
|
|
412
651
|
return { ...raw.metadata, _deleted: true };
|
|
413
652
|
}
|
|
414
|
-
|
|
415
|
-
if (
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
653
|
+
let dek;
|
|
654
|
+
if (raw.payload.dek_wrapped_for_owner) {
|
|
655
|
+
// Append-only deposit: the DEK is sealed to the OWNER's #data-kex key.
|
|
656
|
+
// Only the owner (no delegate/deposit session) can open it. A read
|
|
657
|
+
// delegate holds the CMK, not the owner key, so it must skip deposits.
|
|
658
|
+
if (this.#delegate || this.#deposit) {
|
|
659
|
+
const e = new Error("sdk.data: record is an append-only deposit — only the collection owner can decrypt it (this client holds a delegate/deposit mandate, not the owner key).");
|
|
660
|
+
e.code = -32044; // AITHOS_DATA_DEPOSIT_UNREADABLE (client-side)
|
|
661
|
+
throw e;
|
|
662
|
+
}
|
|
663
|
+
const w = raw.payload.dek_wrapped_for_owner;
|
|
664
|
+
const ownerKexSk = ed25519SeedToX25519PrivateKey(this.#seed);
|
|
665
|
+
try {
|
|
666
|
+
const ephPk = fromBase64(w.ephemeral_public);
|
|
667
|
+
const shared = x25519.getSharedSecret(ownerKexSk, ephPk);
|
|
668
|
+
const wrapKey = hkdf(sha256, shared, DEPOSIT_WRAP_SALT, utf8(w.recipient), 32);
|
|
669
|
+
const wrapAad = aadDepositWrap(this.#did, state.name, raw.record_id, w.recipient);
|
|
670
|
+
dek = new XChaCha20Poly1305(wrapKey).open(fromBase64(w.wrap_nonce), fromBase64(w.wrapped_key), wrapAad);
|
|
671
|
+
wrapKey.fill(0);
|
|
672
|
+
shared.fill(0);
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
ownerKexSk.fill(0);
|
|
676
|
+
}
|
|
677
|
+
if (!dek)
|
|
678
|
+
throw new Error("sdk.data: deposit DEK unwrap failed (wrong owner key or AAD mismatch)");
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
// CMK path (owner / read-write delegate).
|
|
682
|
+
const cmk = this.#cmkCache.get(state.name);
|
|
683
|
+
if (!cmk) {
|
|
684
|
+
throw new Error("sdk.data: CMK not loaded — call ensureCollection first");
|
|
685
|
+
}
|
|
686
|
+
if (raw.payload.dek_wrapped_for_cmk === undefined) {
|
|
687
|
+
throw new Error("sdk.data: record payload has neither dek_wrapped_for_cmk nor dek_wrapped_for_owner");
|
|
688
|
+
}
|
|
689
|
+
const wrapBuf = fromBase64(raw.payload.dek_wrapped_for_cmk);
|
|
690
|
+
const wrapNonce = wrapBuf.slice(0, 24);
|
|
691
|
+
const wrapped = wrapBuf.slice(24);
|
|
692
|
+
const dekAad = aadDekWrap(this.#did, state.name, raw.record_id);
|
|
693
|
+
dek = new XChaCha20Poly1305(cmk).open(wrapNonce, wrapped, dekAad);
|
|
694
|
+
if (!dek)
|
|
695
|
+
throw new Error("sdk.data: DEK unwrap failed");
|
|
696
|
+
}
|
|
427
697
|
try {
|
|
428
698
|
const nonce = fromBase64(raw.payload.nonce);
|
|
429
699
|
const ciphertext = fromBase64(raw.payload.ciphertext);
|
|
@@ -439,6 +709,38 @@ class DataClientImpl {
|
|
|
439
709
|
dek.fill(0);
|
|
440
710
|
}
|
|
441
711
|
}
|
|
712
|
+
/**
|
|
713
|
+
* Append-only deposit insert. Used by the {@link createAppendDataClient}
|
|
714
|
+
* path: builds the record locally (no CMK, no server schema fetch — the
|
|
715
|
+
* caller supplies the schema), seals the DEK to the owner key, and POSTs
|
|
716
|
+
* `insert_record`. The PDS enforces the `data.<col>.append` scope.
|
|
717
|
+
*/
|
|
718
|
+
async _insertDeposit(state, record) {
|
|
719
|
+
if (!this.#deposit) {
|
|
720
|
+
throw new Error("sdk.data: _insertDeposit called without a deposit session");
|
|
721
|
+
}
|
|
722
|
+
const { metadata, payload } = splitRecord(record, state.schema);
|
|
723
|
+
const recordId = `record_${makeUlid()}`;
|
|
724
|
+
const encrypted = this.#encryptPayloadForOwner({
|
|
725
|
+
collectionName: state.name,
|
|
726
|
+
recordId,
|
|
727
|
+
payload,
|
|
728
|
+
ownerKexPublicKey: this.#deposit.ownerKexPublicKey,
|
|
729
|
+
ownerKexDidUrl: this.#deposit.ownerKexDidUrl,
|
|
730
|
+
});
|
|
731
|
+
const r = (await this.#call("/mcp/primitives/write", "aithos.data.insert_record", {
|
|
732
|
+
collection_urn: state.urn,
|
|
733
|
+
record_id: recordId,
|
|
734
|
+
metadata,
|
|
735
|
+
payload: encrypted,
|
|
736
|
+
}));
|
|
737
|
+
return r.record_id;
|
|
738
|
+
}
|
|
739
|
+
/** Build a local collection state for the deposit path (no server fetch:
|
|
740
|
+
* append clients are not authorized to read collection metadata). */
|
|
741
|
+
_depositCollectionState(name, schema) {
|
|
742
|
+
return { name, urn: this.#collectionUrn(name), schema };
|
|
743
|
+
}
|
|
442
744
|
}
|
|
443
745
|
class DataCollectionImpl {
|
|
444
746
|
client;
|
|
@@ -508,6 +810,18 @@ function cryptoRandom(n) {
|
|
|
508
810
|
function utf8(s) {
|
|
509
811
|
return new TextEncoder().encode(s);
|
|
510
812
|
}
|
|
813
|
+
/**
|
|
814
|
+
* Recipient DID URL used for a delegate's CMK wrap. Built from the
|
|
815
|
+
* grantee's Ed25519 public-key multibase so that (a) the owner side
|
|
816
|
+
* (`authorizeDelegate`) and the delegate side (`_ensureCollection`)
|
|
817
|
+
* derive the EXACT same string — it's bound into the wrap AAD and the
|
|
818
|
+
* HKDF info, so any mismatch fails the unwrap — and (b) the string
|
|
819
|
+
* contains `mandate.grantee.pubkey`, which the PDS `authorize_app`
|
|
820
|
+
* handler requires (`wrap.recipient.includes(grantee.pubkey)`).
|
|
821
|
+
*/
|
|
822
|
+
function delegateRecipientDidUrl(granteePubkeyMultibase) {
|
|
823
|
+
return `did:key:${granteePubkeyMultibase}#data-kex`;
|
|
824
|
+
}
|
|
511
825
|
function aadCmkWrap(collectionUrn, recipient) {
|
|
512
826
|
const p = utf8("aithos-data-cmk-v1\0");
|
|
513
827
|
const c = utf8(collectionUrn);
|
|
@@ -532,6 +846,40 @@ function aadRecord(subjectDid, collectionName, recordId) {
|
|
|
532
846
|
const p = utf8("aithos-data-record-v1\0");
|
|
533
847
|
return concat3WithSeps(p, subjectDid, collectionName, recordId);
|
|
534
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* HKDF salt for the append-only deposit DEK wrap. Distinct from the CMK wrap
|
|
851
|
+
* salt so the two key-derivation domains never collide. MUST match
|
|
852
|
+
* `@aithos/data-crypto` `DEPOSIT_WRAP_SALT`.
|
|
853
|
+
*/
|
|
854
|
+
const DEPOSIT_WRAP_SALT = utf8("aithos-data-dek-deposit-wrap-v1");
|
|
855
|
+
/**
|
|
856
|
+
* AAD for the deposit DEK wrap:
|
|
857
|
+
* "aithos-data-dek-deposit-v1\0" ‖ subject ‖ \0 ‖ collection ‖ \0 ‖
|
|
858
|
+
* record ‖ \0 ‖ recipient_did_url
|
|
859
|
+
* MUST match `@aithos/data-crypto` `aadForDepositWrap`.
|
|
860
|
+
*/
|
|
861
|
+
function aadDepositWrap(subjectDid, collectionName, recordId, recipientDidUrl) {
|
|
862
|
+
const prefix = utf8("aithos-data-dek-deposit-v1\0");
|
|
863
|
+
const parts = [subjectDid, collectionName, recordId, recipientDidUrl].map(utf8);
|
|
864
|
+
const sep = new Uint8Array([0]);
|
|
865
|
+
let total = prefix.length;
|
|
866
|
+
for (let i = 0; i < parts.length; i++) {
|
|
867
|
+
total += parts[i].length + (i < parts.length - 1 ? sep.length : 0);
|
|
868
|
+
}
|
|
869
|
+
const out = new Uint8Array(total);
|
|
870
|
+
let off = 0;
|
|
871
|
+
out.set(prefix, off);
|
|
872
|
+
off += prefix.length;
|
|
873
|
+
for (let i = 0; i < parts.length; i++) {
|
|
874
|
+
out.set(parts[i], off);
|
|
875
|
+
off += parts[i].length;
|
|
876
|
+
if (i < parts.length - 1) {
|
|
877
|
+
out.set(sep, off);
|
|
878
|
+
off += sep.length;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return out;
|
|
882
|
+
}
|
|
535
883
|
function concat3WithSeps(prefix, a, b, c) {
|
|
536
884
|
const aa = utf8(a);
|
|
537
885
|
const bb = utf8(b);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -14,7 +14,7 @@ export { WalletNamespace } from "./wallet.js";
|
|
|
14
14
|
export type { ComponentStyle, ExtractArgs, ExtractContent, ExtractData, ExtractForm, ExtractFormField, ExtractHeading, ExtractIconDeclaration, ExtractImage, ExtractLink, ExtractLogo, ExtractMeta, ExtractResult, ExtractSection, ExtractStructure, ExtractStyles, FetchAssetArgs, FetchAssetResult, PaletteEntry, VisualSignature, WebNamespaceDeps, } from "./web.js";
|
|
15
15
|
export { WebNamespace, WEB_EXTRACT_SCOPE } from "./web.js";
|
|
16
16
|
export { AithosAuth, DEFAULT_API_BASE_URL, DEFAULT_AUTH_BASE_URL, } from "./auth.js";
|
|
17
|
-
export type { AithosAuthConfig, AithosSession, ApplyPasswordResetInput, ApplyPasswordResetResult, CompleteSsoFirstLoginInput, CompleteSsoFirstLoginResult, CustodialSignInInput, CustodialSignInResult, CustodialSignUpInput, CustodialSignUpResult, DelegateInfo, ImportMandateInput, OwnerInfo, RequestPasswordResetInput, ResendVerificationInput, SignInInput, SignInWithGoogleOptions, SignInWithRecoveryInput, SignUpInput, SignUpResult, VerifyEmailInput, VerifyEmailResult, } from "./auth.js";
|
|
17
|
+
export type { AcceptInviteInput, AcceptInviteResult, AithosAuthConfig, AithosSession, ApplyPasswordResetInput, ApplyPasswordResetResult, CompleteSsoFirstLoginInput, CompleteSsoFirstLoginResult, CustodialSignInInput, CustodialSignInResult, CustodialSignUpInput, CustodialSignUpResult, DelegateInfo, ImportMandateInput, InviteCustodialInput, InviteCustodialResult, OwnerInfo, RequestPasswordResetInput, ResendVerificationInput, SignInInput, SignInWithGoogleOptions, SignInWithRecoveryInput, SignUpInput, SignUpResult, VerifyEmailInput, VerifyEmailResult, } from "./auth.js";
|
|
18
18
|
export type { SignedEnvelope } from "./internal/envelope.js";
|
|
19
19
|
export { DEFAULT_SESSION_STORAGE_KEY, defaultSessionStore, localStorageStore, noopStore, sessionStorageStore, type AithosSessionStore, } from "./session-store.js";
|
|
20
20
|
export { DEFAULT_KEYSTORE_DB_NAME, defaultKeyStore, indexedDbKeyStore, memoryKeyStore, type AithosKeyStore, type StoredDelegateKeys, type StoredOwnerKeys, } from "./key-store.js";
|
|
@@ -27,6 +27,6 @@ export type { AudienceSet, AppCreditPackId, CreateAppTopupSessionArgs, CreateApp
|
|
|
27
27
|
export * as onboarding from "./onboarding.js";
|
|
28
28
|
export { createBrowserIdentity, browserIdentityFromStored, type BrowserIdentity, } from "@aithos/protocol-client";
|
|
29
29
|
export type { Section } from "@aithos/protocol-client";
|
|
30
|
-
export { createDataClient, type CreateDataClientArgs, type DataClient, type DataCollection, type ListOpts, type AithosSchemaLite, } from "./data.js";
|
|
30
|
+
export { createDataClient, createDelegateDataClient, createAppendDataClient, type CreateDataClientArgs, type CreateDelegateDataClientArgs, type CreateAppendDataClientArgs, type DataClient, type DataCollection, type ReadonlyDataClient, type ReadonlyDataCollection, type AppendOnlyDataClient, type AppendOnlyDataCollection, type ListOpts, type AithosSchemaLite, } from "./data.js";
|
|
31
31
|
export { createAssetsClient, AssetsClient, type CreateAssetsClientArgs, type AttachedContext, type AssetUploadInput, type AssetUploadResult, type AssetFetchResult, type AssetBrief, type ListAssetsOpts, type ThumbnailUploadInput, type ThumbnailUploadResult, type RecipientResolver, type RecipientSet, } from "./assets.js";
|
|
32
32
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/src/index.js
CHANGED
|
@@ -68,7 +68,7 @@ export { createBrowserIdentity, browserIdentityFromStored, } from "@aithos/proto
|
|
|
68
68
|
// `sdk.data` namespace — Aithos data sub-protocol PDS client. Manages
|
|
69
69
|
// the lifecycle of subject-owned, encrypted, schema-validated records.
|
|
70
70
|
// See spec/data/ in the aithos-protocol repo.
|
|
71
|
-
export { createDataClient, } from "./data.js";
|
|
71
|
+
export { createDataClient, createDelegateDataClient, createAppendDataClient, } from "./data.js";
|
|
72
72
|
// `sdk.assets` — Aithos assets sub-protocol PDS client. Upload,
|
|
73
73
|
// fetch, list, ref/unref binary content (images, PDFs, audio, video)
|
|
74
74
|
// owned by a subject. AEAD-encrypted per-asset under AMKs wrapped for
|
|
@@ -22,6 +22,15 @@ export interface SignedEnvelope {
|
|
|
22
22
|
readonly exp: number;
|
|
23
23
|
readonly nonce: string;
|
|
24
24
|
readonly params_hash: string;
|
|
25
|
+
/**
|
|
26
|
+
* Full signed mandate — present ONLY for a delegate-signed envelope
|
|
27
|
+
* (§11.6). When present, `proof.verificationMethod` is the delegate's
|
|
28
|
+
* bare Ed25519 multibase (matching `mandate.grantee.pubkey`) and the
|
|
29
|
+
* server resolves the signer to that key after verifying the mandate.
|
|
30
|
+
* The field is part of the signed bytes (the signature commits to the
|
|
31
|
+
* delegation context), so it cannot be swapped out in transit.
|
|
32
|
+
*/
|
|
33
|
+
readonly mandate?: unknown;
|
|
25
34
|
readonly proof: {
|
|
26
35
|
readonly type: "Ed25519Signature2020";
|
|
27
36
|
readonly verificationMethod: string;
|
|
@@ -54,6 +63,13 @@ export interface SignOwnerEnvelopeArgs {
|
|
|
54
63
|
* matching public key.
|
|
55
64
|
*/
|
|
56
65
|
readonly verificationMethod: string;
|
|
66
|
+
/**
|
|
67
|
+
* Full signed mandate, attached to the envelope for a delegate-signed
|
|
68
|
+
* call (§11.6). When set, `verificationMethod` MUST be the delegate's
|
|
69
|
+
* bare Ed25519 multibase (matching `mandate.grantee.pubkey`) and
|
|
70
|
+
* `signer` MUST be the delegate's key. Omit for owner-path envelopes.
|
|
71
|
+
*/
|
|
72
|
+
readonly mandate?: unknown;
|
|
57
73
|
/** Envelope lifetime in seconds. Default 60. Server caps at 300. */
|
|
58
74
|
readonly ttlSeconds?: number;
|
|
59
75
|
/** Clock override for deterministic tests. Defaults to `new Date()`. */
|
|
@@ -47,6 +47,12 @@ export async function signOwnerEnvelope(args) {
|
|
|
47
47
|
exp,
|
|
48
48
|
nonce,
|
|
49
49
|
params_hash: paramsHash,
|
|
50
|
+
// Attach the mandate (delegate path) so the signature commits to the
|
|
51
|
+
// delegation context. JCS sorts keys, so placement here is irrelevant
|
|
52
|
+
// to the canonical bytes — what matters is that the server (which
|
|
53
|
+
// canonicalizes the full envelope incl. mandate + proof/proofValue="")
|
|
54
|
+
// sees the exact same object.
|
|
55
|
+
...(args.mandate !== undefined ? { mandate: args.mandate } : {}),
|
|
50
56
|
proof: {
|
|
51
57
|
type: "Ed25519Signature2020",
|
|
52
58
|
verificationMethod: args.verificationMethod,
|
package/dist/src/mandates.d.ts
CHANGED
|
@@ -8,7 +8,41 @@ import type { AithosSdkEndpoints } from "./endpoints.js";
|
|
|
8
8
|
* directly in `scopes` is rejected at runtime; the compiler can't enforce
|
|
9
9
|
* it (callers who up-cast to string[] would slip through), so the runtime
|
|
10
10
|
* check is the real gate. */
|
|
11
|
-
export type Scope = "ethos.read.public" | "ethos.read.circle" | "ethos.read.self" | "ethos.write.public" | "ethos.write.circle" | "ethos.write.self";
|
|
11
|
+
export type Scope = "ethos.read.public" | "ethos.read.circle" | "ethos.read.self" | "ethos.write.public" | "ethos.write.circle" | "ethos.write.self" | DataScope;
|
|
12
|
+
/** Action a data mandate may authorize on a collection. `write` implies
|
|
13
|
+
* `read`; `admin` implies `write`. Mirrors the data sub-protocol grammar
|
|
14
|
+
* `data.<collection>.<action>` (Aithos-protocol `spec/data/04-mandates.md`
|
|
15
|
+
* §4.2) and the server-side check `requireScope` in data-backend. */
|
|
16
|
+
export type DataAction = "read" | "write" | "admin";
|
|
17
|
+
/**
|
|
18
|
+
* A **lateral** data capability — deliberately OUTSIDE the
|
|
19
|
+
* `read ⊂ write ⊂ admin` hierarchy (the same way `gamma.write` sits beside
|
|
20
|
+
* the ethos scopes). Keeping it a separate type makes the security invariant
|
|
21
|
+
* structural rather than conventional: `append` can never be reached by
|
|
22
|
+
* widening a `write`/`admin` scope, so it cannot accidentally carry read.
|
|
23
|
+
*
|
|
24
|
+
* `append` authorizes `insert_record` ONLY (no read, update, or delete). The
|
|
25
|
+
* depositor seals each record's DEK to the owner's public key
|
|
26
|
+
* ({@link createAppendDataClient}) and holds no read capability — it cannot
|
|
27
|
+
* decrypt anything in the collection, not even its own deposit.
|
|
28
|
+
*/
|
|
29
|
+
export type DataLateralAction = "append";
|
|
30
|
+
/**
|
|
31
|
+
* A data-access scope: `data.<collection>.<action>`, or the cross-collection
|
|
32
|
+
* wildcard `data.*.<action>`. Examples: `data.contacts.read`,
|
|
33
|
+
* `data.depots.write`, `data.*.read`.
|
|
34
|
+
*
|
|
35
|
+
* Note on `actor_sphere`: data mandates are minted under `actor_sphere:
|
|
36
|
+
* "self"` (the owner's highest-authority sphere). The sphere is *not* the
|
|
37
|
+
* access axis for data — the collection is. `actor_sphere` is informative
|
|
38
|
+
* here; the cryptographic binding is the grantee's key + the CMK wrap, per
|
|
39
|
+
* spec §4.4. A dedicated `#data` sphere key (independent rotation) MAY be
|
|
40
|
+
* introduced later without changing this scope grammar.
|
|
41
|
+
*
|
|
42
|
+
* Collection names MUST NOT contain `.` (the server splits the scope on
|
|
43
|
+
* `.` and reads the first three segments).
|
|
44
|
+
*/
|
|
45
|
+
export type DataScope = `data.${string}.${DataAction}` | `data.${string}.${DataLateralAction}`;
|
|
12
46
|
/**
|
|
13
47
|
* The opt-in scope that authorizes a delegate to spend the subject's
|
|
14
48
|
* compute credits via the Aithos compute proxy. Mirror of
|