@aithos/sdk 0.1.0-alpha.54 → 0.1.0-alpha.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/src/assets.d.ts +19 -3
- package/dist/src/auth.js +50 -72
- package/dist/src/data.d.ts +28 -7
- package/dist/src/data.js +103 -17
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +15 -2
- package/dist/src/internal/cmk-wrap.d.ts +41 -0
- package/dist/src/internal/cmk-wrap.js +132 -0
- package/dist/src/key-store.d.ts +7 -3
- package/dist/src/migrate.d.ts +105 -0
- package/dist/src/migrate.js +367 -0
- package/dist/src/rotate.d.ts +94 -0
- package/dist/src/rotate.js +298 -0
- package/dist/test/migrate.test.d.ts +2 -0
- package/dist/test/migrate.test.js +340 -0
- package/dist/test/rotate-ethos.test.d.ts +2 -0
- package/dist/test/rotate-ethos.test.js +151 -0
- package/dist/test/rotate.test.d.ts +2 -0
- package/dist/test/rotate.test.js +63 -0
- package/dist/test/schema-autoresolve.test.d.ts +2 -0
- package/dist/test/schema-autoresolve.test.js +146 -0
- package/package.json +1 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Hermetic end-to-end tests for the legacy #data sphere migration
|
|
4
|
+
// (src/migrate.ts). No network: an in-memory PDS + registry mock implements
|
|
5
|
+
// the subset of primitives the flow touches. This also serves as the
|
|
6
|
+
// conformance guard between src/internal/cmk-wrap.ts and src/data.ts — the
|
|
7
|
+
// migration re-wraps with cmk-wrap, and a real DataClient (data.ts) must be
|
|
8
|
+
// able to read the result, and vice-versa.
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { strict as assert } from "node:assert";
|
|
11
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
12
|
+
import { createDataClient } from "../src/data.js";
|
|
13
|
+
import { ensureDataSphere, rekeyLegacyCollections, addDataSphereWrap, migrateLegacyEthosToDataSphere, } from "../src/migrate.js";
|
|
14
|
+
import { parseRecoveryFile } from "../src/internal/recovery-file.js";
|
|
15
|
+
function makePds() {
|
|
16
|
+
const collections = new Map();
|
|
17
|
+
const records = new Map();
|
|
18
|
+
const augmentCalls = [];
|
|
19
|
+
const fetchImpl = (async (_url, init) => {
|
|
20
|
+
const body = JSON.parse(init.body);
|
|
21
|
+
const p = body.method;
|
|
22
|
+
const params = body.params ?? {};
|
|
23
|
+
const ok = (result) => new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), {
|
|
24
|
+
status: 200,
|
|
25
|
+
headers: { "content-type": "application/json" },
|
|
26
|
+
});
|
|
27
|
+
const rpcErr = (code, message) => new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, error: { code, message } }), {
|
|
28
|
+
status: 200,
|
|
29
|
+
headers: { "content-type": "application/json" },
|
|
30
|
+
});
|
|
31
|
+
const urnFor = (did, name) => `urn:aithos:collection:${did}:${name}`;
|
|
32
|
+
switch (p) {
|
|
33
|
+
case "aithos.augment_identity": {
|
|
34
|
+
augmentCalls.push(params.did_document);
|
|
35
|
+
return ok({ did: params.did_document.id, idempotent: false, data_sphere_added: true });
|
|
36
|
+
}
|
|
37
|
+
case "aithos.data.create_collection": {
|
|
38
|
+
const urn = urnFor(params.subject_did, params.collection_name);
|
|
39
|
+
collections.set(urn, {
|
|
40
|
+
urn,
|
|
41
|
+
name: params.collection_name,
|
|
42
|
+
schema: params.schema,
|
|
43
|
+
subject_did: params.subject_did,
|
|
44
|
+
cmk_envelope: params.cmk_envelope,
|
|
45
|
+
record_count: 0,
|
|
46
|
+
});
|
|
47
|
+
records.set(urn, new Map());
|
|
48
|
+
return ok({ urn, name: params.collection_name, schema: params.schema, cmk_envelope: params.cmk_envelope });
|
|
49
|
+
}
|
|
50
|
+
case "aithos.data.get_collection": {
|
|
51
|
+
const urn = urnFor(params.subject_did, params.collection_name);
|
|
52
|
+
const c = collections.get(urn);
|
|
53
|
+
if (!c)
|
|
54
|
+
return rpcErr(-32020, "not found");
|
|
55
|
+
return ok({ urn: c.urn, name: c.name, schema: c.schema, cmk_envelope: c.cmk_envelope, record_count: c.record_count });
|
|
56
|
+
}
|
|
57
|
+
case "aithos.data.list_collections": {
|
|
58
|
+
const items = [...collections.values()]
|
|
59
|
+
.filter((c) => c.subject_did === params.subject_did)
|
|
60
|
+
.map((c) => ({ name: c.name, schema: c.schema, record_count: c.record_count }));
|
|
61
|
+
return ok({ items });
|
|
62
|
+
}
|
|
63
|
+
case "aithos.data.insert_record": {
|
|
64
|
+
const recs = records.get(params.collection_urn);
|
|
65
|
+
if (!recs)
|
|
66
|
+
return rpcErr(-32020, "no collection");
|
|
67
|
+
recs.set(params.record_id, {
|
|
68
|
+
record_id: params.record_id,
|
|
69
|
+
metadata: params.metadata,
|
|
70
|
+
payload: params.payload,
|
|
71
|
+
});
|
|
72
|
+
const c = [...collections.values()].find((x) => x.urn === params.collection_urn);
|
|
73
|
+
if (c)
|
|
74
|
+
c.record_count++;
|
|
75
|
+
return ok({ record_id: params.record_id });
|
|
76
|
+
}
|
|
77
|
+
case "aithos.data.get_record": {
|
|
78
|
+
const recs = records.get(params.collection_urn);
|
|
79
|
+
const r = recs?.get(params.record_id);
|
|
80
|
+
if (!r)
|
|
81
|
+
return rpcErr(-32020, "not found");
|
|
82
|
+
return ok(r);
|
|
83
|
+
}
|
|
84
|
+
case "aithos.data.list_records": {
|
|
85
|
+
const recs = records.get(params.collection_urn) ?? new Map();
|
|
86
|
+
return ok({ items: [...recs.values()] });
|
|
87
|
+
}
|
|
88
|
+
case "aithos.data.rotate_cmk": {
|
|
89
|
+
const c = [...collections.values()].find((x) => x.urn === params.collection_urn);
|
|
90
|
+
if (!c)
|
|
91
|
+
return rpcErr(-32020, "not found");
|
|
92
|
+
c.cmk_envelope = params.new_cmk_envelope;
|
|
93
|
+
return ok({ rotated_at: new Date().toISOString(), records_rewrapped: (params.re_wrapped_deks ?? []).length });
|
|
94
|
+
}
|
|
95
|
+
default:
|
|
96
|
+
return rpcErr(-32601, `method not found: ${p}`);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
return { fetchImpl, collections, records, augmentCalls };
|
|
100
|
+
}
|
|
101
|
+
/* -------------------------------------------------------------------------- */
|
|
102
|
+
/* Helpers */
|
|
103
|
+
/* -------------------------------------------------------------------------- */
|
|
104
|
+
function hex(b) {
|
|
105
|
+
let s = "";
|
|
106
|
+
for (const x of b)
|
|
107
|
+
s += x.toString(16).padStart(2, "0");
|
|
108
|
+
return s;
|
|
109
|
+
}
|
|
110
|
+
function hexToBytes(h) {
|
|
111
|
+
const out = new Uint8Array(h.length / 2);
|
|
112
|
+
for (let i = 0; i < out.length; i++)
|
|
113
|
+
out[i] = parseInt(h.substr(i * 2, 2), 16);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
/** A legacy recovery file (root/public/circle/self only — NO #data). */
|
|
117
|
+
function legacyRecoveryJson(id) {
|
|
118
|
+
return JSON.stringify({
|
|
119
|
+
aithos_recovery_version: "0.1.0-plaintext",
|
|
120
|
+
handle: id.handle,
|
|
121
|
+
display_name: id.displayName,
|
|
122
|
+
did: id.did,
|
|
123
|
+
seeds_hex: {
|
|
124
|
+
root: hex(id.root.seed),
|
|
125
|
+
public: hex(id.public.seed),
|
|
126
|
+
circle: hex(id.circle.seed),
|
|
127
|
+
self: hex(id.self.seed),
|
|
128
|
+
},
|
|
129
|
+
saved_at: new Date().toISOString(),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
const API = "https://api.test";
|
|
133
|
+
const PDS = "https://pds.test";
|
|
134
|
+
/* -------------------------------------------------------------------------- */
|
|
135
|
+
/* Tests */
|
|
136
|
+
/* -------------------------------------------------------------------------- */
|
|
137
|
+
test("migrate: legacy collection (self-sealed) → #data, record stays readable", async () => {
|
|
138
|
+
const id = createBrowserIdentity("legacy_user", "Legacy User");
|
|
139
|
+
const did = id.did;
|
|
140
|
+
const pds = makePds();
|
|
141
|
+
const recovery = legacyRecoveryJson(id);
|
|
142
|
+
// 1. Old app created the collection signing under #self (sphereSeed = self).
|
|
143
|
+
const legacyClient = createDataClient({
|
|
144
|
+
pdsUrl: PDS,
|
|
145
|
+
did,
|
|
146
|
+
sphereSeed: id.self.seed,
|
|
147
|
+
verificationMethod: `${did}#self`,
|
|
148
|
+
fetch: pds.fetchImpl,
|
|
149
|
+
});
|
|
150
|
+
await legacyClient.createCollection({ name: "contacts", schema: "aithos.contacts.v1" });
|
|
151
|
+
const recId = await legacyClient.collection("contacts").insert({
|
|
152
|
+
name: "Jean Dupont",
|
|
153
|
+
phone: "+33612345678",
|
|
154
|
+
notes: "secret pre-migration note",
|
|
155
|
+
});
|
|
156
|
+
const before = await legacyClient.collection("contacts").get(recId);
|
|
157
|
+
assert.equal(before.phone, "+33612345678", "legacy read works pre-migration");
|
|
158
|
+
// Sanity: the stored owner wrap is labelled #data-kex but sealed to the self key.
|
|
159
|
+
const urn = `urn:aithos:collection:${did}:contacts`;
|
|
160
|
+
assert.equal(pds.collections.get(urn).cmk_envelope.wraps[0].recipient, `${did}#data-kex`, "owner wrap is labelled #data-kex regardless of the sealing seed");
|
|
161
|
+
// 2. ensureDataSphere — adds #data, returns a recovery file carrying it.
|
|
162
|
+
const ensure = await ensureDataSphere(recovery, { apiBaseUrl: API, pdsUrl: PDS, fetch: pds.fetchImpl });
|
|
163
|
+
assert.equal(ensure.added, true);
|
|
164
|
+
assert.equal(ensure.dataSeedHex.length, 64, "a 32-byte #data seed was generated");
|
|
165
|
+
assert.equal(pds.augmentCalls.length, 1, "augment_identity was called once");
|
|
166
|
+
// 3. rekey — re-wrap CMK from self to #data.
|
|
167
|
+
const rekey = await rekeyLegacyCollections(parseRecoveryFile(ensure.updatedRecoveryFile), {
|
|
168
|
+
pdsUrl: PDS,
|
|
169
|
+
fetch: pds.fetchImpl,
|
|
170
|
+
});
|
|
171
|
+
assert.equal(rekey.collections.length, 1);
|
|
172
|
+
assert.equal(rekey.collections[0].status, "rekeyed");
|
|
173
|
+
assert.equal(rekey.collections[0].unwrappedWith, "self", "auto-discovered the self seed");
|
|
174
|
+
// owner wrap recipient label unchanged; still exactly one owner wrap.
|
|
175
|
+
const envAfter = pds.collections.get(urn).cmk_envelope;
|
|
176
|
+
const ownerWraps = envAfter.wraps.filter((w) => w.recipient === `${did}#data-kex`);
|
|
177
|
+
assert.equal(ownerWraps.length, 1, "exactly one owner wrap after rekey");
|
|
178
|
+
// 4. A fresh #data client reads the SAME record and decrypts it.
|
|
179
|
+
const dataClient = createDataClient({
|
|
180
|
+
pdsUrl: PDS,
|
|
181
|
+
did,
|
|
182
|
+
sphereSeed: hexToBytes(ensure.dataSeedHex),
|
|
183
|
+
verificationMethod: `${did}#data`,
|
|
184
|
+
fetch: pds.fetchImpl,
|
|
185
|
+
});
|
|
186
|
+
const after = await dataClient.collection("contacts").get(recId);
|
|
187
|
+
assert.ok(after, "record fetched post-migration");
|
|
188
|
+
assert.equal(after.phone, "+33612345678", "phone decrypts under #data");
|
|
189
|
+
assert.equal(after.notes, "secret pre-migration note", "notes decrypt under #data");
|
|
190
|
+
// 5. Idempotence — re-running rekey is a no-op (already-data).
|
|
191
|
+
const rekey2 = await rekeyLegacyCollections(parseRecoveryFile(ensure.updatedRecoveryFile), {
|
|
192
|
+
pdsUrl: PDS,
|
|
193
|
+
fetch: pds.fetchImpl,
|
|
194
|
+
});
|
|
195
|
+
assert.equal(rekey2.collections[0].status, "already-data");
|
|
196
|
+
});
|
|
197
|
+
test("migrate: dry-run reports a planned rekey without mutating the envelope", async () => {
|
|
198
|
+
const id = createBrowserIdentity("dry_user", "Dry User");
|
|
199
|
+
const did = id.did;
|
|
200
|
+
const pds = makePds();
|
|
201
|
+
const legacyClient = createDataClient({
|
|
202
|
+
pdsUrl: PDS,
|
|
203
|
+
did,
|
|
204
|
+
sphereSeed: id.public.seed, // sealed under #public this time
|
|
205
|
+
verificationMethod: `${did}#public`,
|
|
206
|
+
fetch: pds.fetchImpl,
|
|
207
|
+
});
|
|
208
|
+
await legacyClient.createCollection({ name: "leads", schema: "aithos.contacts.v1" });
|
|
209
|
+
await legacyClient.collection("leads").insert({ name: "Ana", phone: "+34000" });
|
|
210
|
+
const urn = `urn:aithos:collection:${did}:leads`;
|
|
211
|
+
const envBefore = JSON.stringify(pds.collections.get(urn).cmk_envelope);
|
|
212
|
+
const ensure = await ensureDataSphere(legacyRecoveryJson(id), { apiBaseUrl: API, pdsUrl: PDS, fetch: pds.fetchImpl });
|
|
213
|
+
const rekey = await rekeyLegacyCollections(parseRecoveryFile(ensure.updatedRecoveryFile), {
|
|
214
|
+
pdsUrl: PDS,
|
|
215
|
+
fetch: pds.fetchImpl,
|
|
216
|
+
dryRun: true,
|
|
217
|
+
});
|
|
218
|
+
assert.equal(rekey.dryRun, true);
|
|
219
|
+
assert.equal(rekey.collections[0].status, "planned");
|
|
220
|
+
assert.equal(rekey.collections[0].unwrappedWith, "public");
|
|
221
|
+
assert.equal(JSON.stringify(pds.collections.get(urn).cmk_envelope), envBefore, "dry-run did NOT touch the stored envelope");
|
|
222
|
+
});
|
|
223
|
+
test("migrate: delegate wraps are preserved across rekey", async () => {
|
|
224
|
+
const id = createBrowserIdentity("deleg_user", "Deleg User");
|
|
225
|
+
const did = id.did;
|
|
226
|
+
const pds = makePds();
|
|
227
|
+
const legacyClient = createDataClient({
|
|
228
|
+
pdsUrl: PDS,
|
|
229
|
+
did,
|
|
230
|
+
sphereSeed: id.self.seed,
|
|
231
|
+
verificationMethod: `${did}#self`,
|
|
232
|
+
fetch: pds.fetchImpl,
|
|
233
|
+
});
|
|
234
|
+
await legacyClient.createCollection({ name: "shared", schema: "aithos.contacts.v1" });
|
|
235
|
+
// Inject a fake delegate wrap (a did:key recipient) directly into the stored
|
|
236
|
+
// envelope to simulate a prior authorize_app.
|
|
237
|
+
const urn = `urn:aithos:collection:${did}:shared`;
|
|
238
|
+
const env = pds.collections.get(urn).cmk_envelope;
|
|
239
|
+
env.wraps.push({
|
|
240
|
+
recipient: "did:key:zDelegateKey#data-kex",
|
|
241
|
+
// The other fields aren't read by the migration (it filters by recipient).
|
|
242
|
+
});
|
|
243
|
+
const ensure = await ensureDataSphere(legacyRecoveryJson(id), { apiBaseUrl: API, pdsUrl: PDS, fetch: pds.fetchImpl });
|
|
244
|
+
const rekey = await rekeyLegacyCollections(parseRecoveryFile(ensure.updatedRecoveryFile), {
|
|
245
|
+
pdsUrl: PDS,
|
|
246
|
+
fetch: pds.fetchImpl,
|
|
247
|
+
});
|
|
248
|
+
assert.equal(rekey.collections[0].status, "rekeyed");
|
|
249
|
+
assert.equal(rekey.collections[0].delegateWrapsPreserved, 1);
|
|
250
|
+
const after = pds.collections.get(urn).cmk_envelope;
|
|
251
|
+
assert.ok(after.wraps.some((w) => w.recipient === "did:key:zDelegateKey#data-kex"), "delegate wrap survived the rekey");
|
|
252
|
+
assert.equal(after.wraps.filter((w) => w.recipient === `${did}#data-kex`).length, 1, "still exactly one owner wrap");
|
|
253
|
+
});
|
|
254
|
+
test("migrate: add-data-wrap keeps the legacy reader AND enables #data (dual-read)", async () => {
|
|
255
|
+
// Mirrors linkedone: a collection created+read under #root (post-store passes
|
|
256
|
+
// sphereSeed: rootSeed). We must NOT break the #root reader, but ALSO let a
|
|
257
|
+
// #data client read the same data.
|
|
258
|
+
const id = createBrowserIdentity("dual_user", "Dual User");
|
|
259
|
+
const did = id.did;
|
|
260
|
+
const pds = makePds();
|
|
261
|
+
// Legacy app wraps the CMK under #root (recipient label is #data-kex anyway).
|
|
262
|
+
const rootClient = createDataClient({
|
|
263
|
+
pdsUrl: PDS,
|
|
264
|
+
did,
|
|
265
|
+
sphereSeed: id.root.seed,
|
|
266
|
+
verificationMethod: `${did}#root`,
|
|
267
|
+
fetch: pds.fetchImpl,
|
|
268
|
+
});
|
|
269
|
+
await rootClient.createCollection({ name: "posts", schema: "aithos.contacts.v1" });
|
|
270
|
+
const recId = await rootClient.collection("posts").insert({ name: "Léa", phone: "+33secret-root" });
|
|
271
|
+
// Onboard onto #data in ADD mode (non-destructive).
|
|
272
|
+
const ensure = await ensureDataSphere(legacyRecoveryJson(id), { apiBaseUrl: API, pdsUrl: PDS, fetch: pds.fetchImpl });
|
|
273
|
+
const report = await addDataSphereWrap(parseRecoveryFile(ensure.updatedRecoveryFile), {
|
|
274
|
+
pdsUrl: PDS,
|
|
275
|
+
fetch: pds.fetchImpl,
|
|
276
|
+
});
|
|
277
|
+
assert.equal(report.collections[0].status, "data-wrap-added");
|
|
278
|
+
assert.equal(report.collections[0].unwrappedWith, "root");
|
|
279
|
+
// Two owner wraps now share the #data-kex label.
|
|
280
|
+
const urn = `urn:aithos:collection:${did}:posts`;
|
|
281
|
+
const ownerWraps = pds.collections.get(urn).cmk_envelope.wraps.filter((w) => w.recipient === `${did}#data-kex`);
|
|
282
|
+
assert.equal(ownerWraps.length, 2, "legacy + #data owner wraps coexist");
|
|
283
|
+
// 1) The legacy #root reader STILL works (unchanged app).
|
|
284
|
+
const rootClient2 = createDataClient({
|
|
285
|
+
pdsUrl: PDS,
|
|
286
|
+
did,
|
|
287
|
+
sphereSeed: id.root.seed,
|
|
288
|
+
verificationMethod: `${did}#root`,
|
|
289
|
+
fetch: pds.fetchImpl,
|
|
290
|
+
});
|
|
291
|
+
const viaRoot = await rootClient2.collection("posts").get(recId);
|
|
292
|
+
assert.equal(viaRoot.phone, "+33secret-root", "#root reader still decrypts (multi-wrap reader tries all)");
|
|
293
|
+
// 2) A #data client reads the SAME record.
|
|
294
|
+
const dataClient = createDataClient({
|
|
295
|
+
pdsUrl: PDS,
|
|
296
|
+
did,
|
|
297
|
+
sphereSeed: hexToBytes(ensure.dataSeedHex),
|
|
298
|
+
verificationMethod: `${did}#data`,
|
|
299
|
+
fetch: pds.fetchImpl,
|
|
300
|
+
});
|
|
301
|
+
const viaData = await dataClient.collection("posts").get(recId);
|
|
302
|
+
assert.equal(viaData.phone, "+33secret-root", "#data reader decrypts the same data");
|
|
303
|
+
// Idempotent: re-running add is a no-op.
|
|
304
|
+
const again = await addDataSphereWrap(parseRecoveryFile(ensure.updatedRecoveryFile), { pdsUrl: PDS, fetch: pds.fetchImpl });
|
|
305
|
+
assert.equal(again.collections[0].status, "already-data");
|
|
306
|
+
});
|
|
307
|
+
test("migrate: combined helper runs ensure + rekey and returns updated recovery", async () => {
|
|
308
|
+
const id = createBrowserIdentity("combo_user", "Combo User");
|
|
309
|
+
const did = id.did;
|
|
310
|
+
const pds = makePds();
|
|
311
|
+
const legacyClient = createDataClient({
|
|
312
|
+
pdsUrl: PDS,
|
|
313
|
+
did,
|
|
314
|
+
sphereSeed: id.self.seed,
|
|
315
|
+
verificationMethod: `${did}#self`,
|
|
316
|
+
fetch: pds.fetchImpl,
|
|
317
|
+
});
|
|
318
|
+
await legacyClient.createCollection({ name: "contacts", schema: "aithos.contacts.v1" });
|
|
319
|
+
const recId = await legacyClient.collection("contacts").insert({ name: "Zoe", phone: "+1999" });
|
|
320
|
+
const out = await migrateLegacyEthosToDataSphere(legacyRecoveryJson(id), {
|
|
321
|
+
apiBaseUrl: API,
|
|
322
|
+
pdsUrl: PDS,
|
|
323
|
+
fetch: pds.fetchImpl,
|
|
324
|
+
});
|
|
325
|
+
assert.equal(out.ensure.added, true);
|
|
326
|
+
assert.equal(out.rekey.collections[0].status, "rekeyed");
|
|
327
|
+
const parsed = parseRecoveryFile(out.updatedRecoveryFile);
|
|
328
|
+
assert.equal(typeof parsed.seedsHex.data, "string");
|
|
329
|
+
// Read back under #data via the returned seed.
|
|
330
|
+
const dataClient = createDataClient({
|
|
331
|
+
pdsUrl: PDS,
|
|
332
|
+
did,
|
|
333
|
+
sphereSeed: hexToBytes(out.ensure.dataSeedHex),
|
|
334
|
+
verificationMethod: `${did}#data`,
|
|
335
|
+
fetch: pds.fetchImpl,
|
|
336
|
+
});
|
|
337
|
+
const after = await dataClient.collection("contacts").get(recId);
|
|
338
|
+
assert.equal(after.phone, "+1999");
|
|
339
|
+
});
|
|
340
|
+
//# sourceMappingURL=migrate.test.js.map
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Hermetic test of the rotateEthos orchestration, MINUS the live zone recopy
|
|
4
|
+
// (the protocol-client editor uses ambient fetch and can't be mocked here, so
|
|
5
|
+
// we drive the no-edition path). Covers: did.json rotation (rotate_sphere_key
|
|
6
|
+
// with a root-verifiable rotated doc) + collections dual-wrapped toward the NEW
|
|
7
|
+
// #data, readable under the new key while the old wrap is preserved.
|
|
8
|
+
import { test } from "node:test";
|
|
9
|
+
import { strict as assert } from "node:assert";
|
|
10
|
+
import { createBrowserIdentity, signedDidDocument } from "@aithos/protocol-client";
|
|
11
|
+
import { verifyDidDocument } from "@aithos/protocol-core";
|
|
12
|
+
import { createDataClient } from "../src/data.js";
|
|
13
|
+
import { rotateEthos } from "../src/rotate.js";
|
|
14
|
+
import { parseRecoveryFile } from "../src/internal/recovery-file.js";
|
|
15
|
+
const API = "https://api.test";
|
|
16
|
+
const PDS = "https://pds.test";
|
|
17
|
+
function hex(b) {
|
|
18
|
+
let s = "";
|
|
19
|
+
for (const x of b)
|
|
20
|
+
s += x.toString(16).padStart(2, "0");
|
|
21
|
+
return s;
|
|
22
|
+
}
|
|
23
|
+
function hexToBytes(h) {
|
|
24
|
+
const o = new Uint8Array(h.length / 2);
|
|
25
|
+
for (let i = 0; i < o.length; i++)
|
|
26
|
+
o[i] = parseInt(h.substr(i * 2, 2), 16);
|
|
27
|
+
return o;
|
|
28
|
+
}
|
|
29
|
+
/** Mock api.aithos.be (identity) + pds.aithos.be (data). No edition exists. */
|
|
30
|
+
function makeMock(initialDidDoc) {
|
|
31
|
+
let didDoc = initialDidDoc;
|
|
32
|
+
const collections = new Map();
|
|
33
|
+
const records = new Map();
|
|
34
|
+
let rotateCalls = 0;
|
|
35
|
+
const fetchImpl = (async (url, init) => {
|
|
36
|
+
const body = JSON.parse(init.body);
|
|
37
|
+
const m = body.method;
|
|
38
|
+
const p = body.params ?? {};
|
|
39
|
+
const ok = (result) => new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), { status: 200, headers: { "content-type": "application/json" } });
|
|
40
|
+
const rpcErr = (code, message) => new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, error: { code, message } }), { status: 200, headers: { "content-type": "application/json" } });
|
|
41
|
+
const urnFor = (did, name) => `urn:aithos:collection:${did}:${name}`;
|
|
42
|
+
switch (m) {
|
|
43
|
+
// --- identity (api) ---
|
|
44
|
+
case "aithos.get_identity":
|
|
45
|
+
return ok({ object: didDoc });
|
|
46
|
+
case "aithos.get_ethos_manifest":
|
|
47
|
+
return rpcErr(-32020, "not found: edition for " + p.did); // no edition
|
|
48
|
+
case "aithos.rotate_sphere_key":
|
|
49
|
+
didDoc = p.new_did_document;
|
|
50
|
+
rotateCalls++;
|
|
51
|
+
return ok({ did: p.new_did_document.id, rotated_spheres: ["public", "circle", "self"] });
|
|
52
|
+
// --- data (pds) ---
|
|
53
|
+
case "aithos.data.create_collection": {
|
|
54
|
+
const urn = urnFor(p.subject_did, p.collection_name);
|
|
55
|
+
collections.set(urn, { urn, name: p.collection_name, schema: p.schema, subject_did: p.subject_did, cmk_envelope: p.cmk_envelope, record_count: 0 });
|
|
56
|
+
records.set(urn, new Map());
|
|
57
|
+
return ok({ urn, name: p.collection_name, schema: p.schema, cmk_envelope: p.cmk_envelope });
|
|
58
|
+
}
|
|
59
|
+
case "aithos.data.get_collection": {
|
|
60
|
+
const c = collections.get(urnFor(p.subject_did, p.collection_name));
|
|
61
|
+
if (!c)
|
|
62
|
+
return rpcErr(-32020, "not found");
|
|
63
|
+
return ok({ urn: c.urn, name: c.name, schema: c.schema, cmk_envelope: c.cmk_envelope, record_count: c.record_count });
|
|
64
|
+
}
|
|
65
|
+
case "aithos.data.list_collections":
|
|
66
|
+
return ok({ items: [...collections.values()].filter((c) => c.subject_did === p.subject_did).map((c) => ({ name: c.name, schema: c.schema, record_count: c.record_count })) });
|
|
67
|
+
case "aithos.data.insert_record": {
|
|
68
|
+
const recs = records.get(p.collection_urn);
|
|
69
|
+
recs.set(p.record_id, { record_id: p.record_id, metadata: p.metadata, payload: p.payload });
|
|
70
|
+
return ok({ record_id: p.record_id });
|
|
71
|
+
}
|
|
72
|
+
case "aithos.data.get_record": {
|
|
73
|
+
const r = records.get(p.collection_urn)?.get(p.record_id);
|
|
74
|
+
return r ? ok(r) : rpcErr(-32020, "not found");
|
|
75
|
+
}
|
|
76
|
+
case "aithos.data.rotate_cmk": {
|
|
77
|
+
const c = [...collections.values()].find((x) => x.urn === p.collection_urn);
|
|
78
|
+
c.cmk_envelope = p.new_cmk_envelope;
|
|
79
|
+
return ok({ rotated_at: new Date().toISOString() });
|
|
80
|
+
}
|
|
81
|
+
default:
|
|
82
|
+
return rpcErr(-32601, `method not found: ${m}`);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
return { fetchImpl, getDidDoc: () => didDoc, collections, rotateCalls: () => rotateCalls };
|
|
86
|
+
}
|
|
87
|
+
test("rotateEthos: rotates did.json (verifiable) + dual-wraps collections to the new #data", async () => {
|
|
88
|
+
// Legacy identity: published did.json WITHOUT #data (strip it).
|
|
89
|
+
const id = createBrowserIdentity("rotee", "Rotee");
|
|
90
|
+
const legacyDoc = signedDidDocument({ ...id, data: undefined });
|
|
91
|
+
const mock = makeMock(legacyDoc);
|
|
92
|
+
const did = id.did;
|
|
93
|
+
const legacyRecovery = JSON.stringify({
|
|
94
|
+
aithos_recovery_version: "0.1.0-plaintext",
|
|
95
|
+
handle: id.handle,
|
|
96
|
+
display_name: id.displayName,
|
|
97
|
+
did,
|
|
98
|
+
seeds_hex: { root: hex(id.root.seed), public: hex(id.public.seed), circle: hex(id.circle.seed), self: hex(id.self.seed) },
|
|
99
|
+
});
|
|
100
|
+
// A collection created + sealed under the OLD #self key.
|
|
101
|
+
const oldClient = createDataClient({ pdsUrl: PDS, did, sphereSeed: id.self.seed, verificationMethod: `${did}#self`, fetch: mock.fetchImpl });
|
|
102
|
+
await oldClient.createCollection({ name: "contacts", schema: "aithos.contacts.v1" });
|
|
103
|
+
const recId = await oldClient.collection("contacts").insert({ name: "Iris", phone: "+33-rot-secret" });
|
|
104
|
+
// Rotate everything. No edition exists → zones skipped automatically.
|
|
105
|
+
const result = await rotateEthos(legacyRecovery, { apiBaseUrl: API, pdsUrl: PDS, fetch: mock.fetchImpl });
|
|
106
|
+
assert.equal(result.hadEdition, false);
|
|
107
|
+
assert.equal(result.editionRepublished, false);
|
|
108
|
+
assert.equal(mock.rotateCalls(), 1, "rotate_sphere_key called once");
|
|
109
|
+
// The newly published did.json verifies under root, rotated all 3 spheres, added #data.
|
|
110
|
+
const newDoc = mock.getDidDoc();
|
|
111
|
+
assert.equal(verifyDidDocument(newDoc), true, "rotated did.json verifies under root");
|
|
112
|
+
assert.equal(newDoc.aithos.rotated.length, 3);
|
|
113
|
+
assert.ok(newDoc.verificationMethod.some((v) => v.id === `${did}#data`), "#data added");
|
|
114
|
+
for (const s of ["public", "circle", "self"]) {
|
|
115
|
+
const oldK = legacyDoc.verificationMethod.find((v) => v.id === `${did}#${s}`).publicKeyMultibase;
|
|
116
|
+
const newK = newDoc.verificationMethod.find((v) => v.id === `${did}#${s}`).publicKeyMultibase;
|
|
117
|
+
assert.notEqual(newK, oldK, `${s} rotated`);
|
|
118
|
+
}
|
|
119
|
+
// Collections re-keyed toward the new #data (dual). Read under the NEW #data.
|
|
120
|
+
assert.equal(result.collections.collections[0].status, "data-wrap-added");
|
|
121
|
+
const newRec = parseRecoveryFile(result.updatedRecoveryFile);
|
|
122
|
+
assert.ok(newRec.seedsHex.data && newRec.seedsHex.data.length === 64, "new recovery carries #data");
|
|
123
|
+
// all sphere seeds changed
|
|
124
|
+
assert.notEqual(newRec.seedsHex.public, hex(id.public.seed));
|
|
125
|
+
const dataClient = createDataClient({ pdsUrl: PDS, did, sphereSeed: hexToBytes(newRec.seedsHex.data), verificationMethod: `${did}#data`, fetch: mock.fetchImpl });
|
|
126
|
+
const got = await dataClient.collection("contacts").get(recId);
|
|
127
|
+
assert.equal(got.phone, "+33-rot-secret", "record decrypts under the NEW #data");
|
|
128
|
+
// The OLD #self reader still works (dual: old wrap preserved).
|
|
129
|
+
const selfClient = createDataClient({ pdsUrl: PDS, did, sphereSeed: id.self.seed, verificationMethod: `${did}#self`, fetch: mock.fetchImpl });
|
|
130
|
+
const viaSelf = await selfClient.collection("contacts").get(recId);
|
|
131
|
+
assert.equal(viaSelf.phone, "+33-rot-secret", "old #self reader still decrypts");
|
|
132
|
+
});
|
|
133
|
+
test("rotateEthos: refuses to skip zones when an edition exists (no force)", async () => {
|
|
134
|
+
const id = createBrowserIdentity("rotee2", "Rotee2");
|
|
135
|
+
const mock = makeMock(signedDidDocument({ ...id, data: undefined }));
|
|
136
|
+
// Make get_ethos_manifest succeed → an edition "exists".
|
|
137
|
+
const patched = (async (url, init) => {
|
|
138
|
+
const body = JSON.parse(init.body);
|
|
139
|
+
if (body.method === "aithos.get_ethos_manifest") {
|
|
140
|
+
return new Response(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { object: { edition: { height: 0 } } } }), { status: 200 });
|
|
141
|
+
}
|
|
142
|
+
return mock.fetchImpl(url, init);
|
|
143
|
+
});
|
|
144
|
+
const legacyRecovery = JSON.stringify({
|
|
145
|
+
aithos_recovery_version: "0.1.0-plaintext",
|
|
146
|
+
handle: id.handle, display_name: id.displayName, did: id.did,
|
|
147
|
+
seeds_hex: { root: hex(id.root.seed), public: hex(id.public.seed), circle: hex(id.circle.seed), self: hex(id.self.seed) },
|
|
148
|
+
});
|
|
149
|
+
await assert.rejects(() => rotateEthos(legacyRecovery, { apiBaseUrl: API, pdsUrl: PDS, fetch: patched, skipZones: true }), /would orphan that content/);
|
|
150
|
+
});
|
|
151
|
+
//# sourceMappingURL=rotate-ethos.test.js.map
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Unit tests for buildSignedRotatedDidDocument: the rotated did.json must (1)
|
|
4
|
+
// verify under the unchanged root key, (2) grow aithos.rotated[] by one entry
|
|
5
|
+
// per changed Ethos sphere, (3) carry the #data sphere, (4) keep the DID.
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import { strict as assert } from "node:assert";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { createBrowserIdentity, signedDidDocument } from "@aithos/protocol-client";
|
|
10
|
+
import { verifyDidDocument } from "@aithos/protocol-core";
|
|
11
|
+
import { buildSignedRotatedDidDocument } from "../src/rotate.js";
|
|
12
|
+
function seed() {
|
|
13
|
+
return new Uint8Array(randomBytes(32));
|
|
14
|
+
}
|
|
15
|
+
test("rotate: rotated did.json verifies under root, grows rotated[], carries #data, keeps DID", () => {
|
|
16
|
+
// Baseline: a legacy-style identity (no rotation history).
|
|
17
|
+
const id = createBrowserIdentity("rot_user", "Rot User");
|
|
18
|
+
const previousDoc = signedDidDocument(id);
|
|
19
|
+
assert.equal(previousDoc.aithos.rotated.length, 0, "baseline has empty rotation history");
|
|
20
|
+
// Rotate all three Ethos spheres + #data; keep root.
|
|
21
|
+
const seeds = { root: id.root.seed, public: seed(), circle: seed(), self: seed(), data: seed() };
|
|
22
|
+
const rotated = buildSignedRotatedDidDocument(seeds, previousDoc, "Rot User", "key-rotation");
|
|
23
|
+
// 1. Root signature still valid (root unchanged).
|
|
24
|
+
assert.equal(verifyDidDocument(rotated), true, "rotated doc verifies under root");
|
|
25
|
+
// 2. DID unchanged.
|
|
26
|
+
assert.equal(rotated.id, previousDoc.id);
|
|
27
|
+
// 3. rotated[] grew by exactly 3 (public, circle, self all changed).
|
|
28
|
+
assert.equal(rotated.aithos.rotated.length, 3);
|
|
29
|
+
const spheres = rotated.aithos.rotated.map((r) => r.sphere).sort();
|
|
30
|
+
assert.deepEqual(spheres, ["circle", "public", "self"]);
|
|
31
|
+
// Each entry records the OLD key.
|
|
32
|
+
for (const entry of rotated.aithos.rotated) {
|
|
33
|
+
const prev = previousDoc.verificationMethod.find((v) => v.id === `${rotated.id}#${entry.sphere}`);
|
|
34
|
+
assert.equal(entry.previous_key, prev.publicKeyMultibase, `${entry.sphere} records previous key`);
|
|
35
|
+
}
|
|
36
|
+
// 4. New keys actually differ + #data present.
|
|
37
|
+
for (const sphere of ["public", "circle", "self"]) {
|
|
38
|
+
const oldVm = previousDoc.verificationMethod.find((v) => v.id === `${rotated.id}#${sphere}`);
|
|
39
|
+
const newVm = rotated.verificationMethod.find((v) => v.id === `${rotated.id}#${sphere}`);
|
|
40
|
+
assert.notEqual(newVm.publicKeyMultibase, oldVm.publicKeyMultibase, `${sphere} key rotated`);
|
|
41
|
+
}
|
|
42
|
+
assert.ok(rotated.verificationMethod.some((v) => v.id === `${rotated.id}#data`), "#data VM present");
|
|
43
|
+
assert.ok((rotated.keyAgreement ?? []).some((v) => v.id === `${rotated.id}#data-kex`), "#data-kex present");
|
|
44
|
+
});
|
|
45
|
+
test("rotate: history is carried forward across a second rotation", () => {
|
|
46
|
+
const id = createBrowserIdentity("rot2", "Rot2");
|
|
47
|
+
const doc0 = signedDidDocument(id);
|
|
48
|
+
const seeds1 = { root: id.root.seed, public: seed(), circle: seed(), self: seed(), data: seed() };
|
|
49
|
+
const doc1 = buildSignedRotatedDidDocument(seeds1, doc0, "Rot2");
|
|
50
|
+
assert.equal(doc1.aithos.rotated.length, 3);
|
|
51
|
+
// Second rotation from doc1 → 3 more entries (total 6).
|
|
52
|
+
const seeds2 = { root: id.root.seed, public: seed(), circle: seed(), self: seed(), data: seeds1.data };
|
|
53
|
+
const doc2 = buildSignedRotatedDidDocument(seeds2, doc1, "Rot2");
|
|
54
|
+
assert.equal(doc2.aithos.rotated.length, 6, "prior history preserved + new entries appended");
|
|
55
|
+
assert.equal(verifyDidDocument(doc2), true);
|
|
56
|
+
});
|
|
57
|
+
test("rotate: refuses if root seed does not match the DID", () => {
|
|
58
|
+
const id = createBrowserIdentity("rot3", "Rot3");
|
|
59
|
+
const doc0 = signedDidDocument(id);
|
|
60
|
+
const seeds = { root: seed() /* WRONG root */, public: seed(), circle: seed(), self: seed() };
|
|
61
|
+
assert.throws(() => buildSignedRotatedDidDocument(seeds, doc0, "Rot3"), /root .* never rotated/);
|
|
62
|
+
});
|
|
63
|
+
//# sourceMappingURL=rotate.test.js.map
|