@aithos/sdk 0.1.0-alpha.54 → 0.1.0-alpha.55

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.
@@ -0,0 +1,105 @@
1
+ import { type ParsedRecoveryFile } from "./internal/recovery-file.js";
2
+ import { type CmkEnvelope } from "./internal/cmk-wrap.js";
3
+ export interface MigrateOptions {
4
+ /** api.aithos.be base URL. Defaults to {@link DEFAULT_API_BASE_URL}. */
5
+ readonly apiBaseUrl?: string;
6
+ /** PDS base URL. Defaults to `https://pds.aithos.be`. Pass the raw
7
+ * execute-api URL when the vanity host isn't live in your environment. */
8
+ readonly pdsUrl?: string;
9
+ /** Custom fetch (tests / SSR). Defaults to globalThis.fetch. */
10
+ readonly fetch?: typeof fetch;
11
+ /** When true, compute and report planned rekeys but DO NOT write. */
12
+ readonly dryRun?: boolean;
13
+ /**
14
+ * How to onboard a collection onto #data:
15
+ * - "replace" (default): swap the owner wrap from the legacy sphere to
16
+ * #data. Use for a clean cutover when NOTHING still reads the collection
17
+ * under the old key. WARNING: breaks any app still reading under the
18
+ * legacy key (e.g. linkedone reading under #root).
19
+ * - "add": KEEP the legacy owner wrap and ADD a second owner wrap sealed to
20
+ * #data (dual-read). Both the legacy app and a #data client can read the
21
+ * same data. Requires @aithos/sdk with the multi-wrap owner reader
22
+ * (this release). Safe, non-destructive — recommended for live accounts.
23
+ */
24
+ readonly mode?: "replace" | "add";
25
+ /**
26
+ * Re-key collections toward a DIFFERENT `#data` key than the one in the
27
+ * recovery file. Used by `rotateEthos`: unwrap with the recovery's (old)
28
+ * sphere seeds, wrap toward this NEW `#data` seed. When omitted, the target
29
+ * is the recovery file's own `#data` seed (the normal migration case).
30
+ */
31
+ readonly targetDataSeedHex?: string;
32
+ /** Progress callback for CLIs / UIs. */
33
+ readonly onProgress?: (e: MigrateProgress) => void;
34
+ }
35
+ export type MigrateProgress = {
36
+ phase: "ensure-data-sphere";
37
+ status: "start" | "added" | "already-present";
38
+ } | {
39
+ phase: "rekey";
40
+ status: "collection";
41
+ collection: string;
42
+ result: CollectionRekeyStatus;
43
+ };
44
+ export interface EnsureDataSphereResult {
45
+ readonly did: string;
46
+ /** True when this call added #data (false = already present server-side). */
47
+ readonly added: boolean;
48
+ /** The #data sphere seed (hex). The caller MUST persist this — it's the new
49
+ * key the account signs/reads data under. Returned even when `added` is
50
+ * false (it's the same seed that's now published). */
51
+ readonly dataSeedHex: string;
52
+ /** Convenience: the recovery file serialized WITH the #data seed merged in. */
53
+ readonly updatedRecoveryFile: string;
54
+ }
55
+ export type CollectionRekeyStatus = "rekeyed" | "data-wrap-added" | "already-data" | "planned" | "skipped-no-owner-wrap" | "unwrap-failed";
56
+ export interface CollectionRekeyEntry {
57
+ readonly name: string;
58
+ readonly urn: string;
59
+ readonly status: CollectionRekeyStatus;
60
+ /** Which seed successfully unwrapped the CMK (auto-discovery result). */
61
+ readonly unwrappedWith?: SphereName;
62
+ /** Count of non-owner (delegate) wraps preserved into the new envelope. */
63
+ readonly delegateWrapsPreserved?: number;
64
+ /** Pre-mutation cmk_envelope, kept so a human can roll back if needed. */
65
+ readonly backupEnvelope?: CmkEnvelope;
66
+ }
67
+ export interface RekeyReport {
68
+ readonly did: string;
69
+ readonly dryRun: boolean;
70
+ readonly collections: readonly CollectionRekeyEntry[];
71
+ }
72
+ export interface FullMigrationResult {
73
+ readonly ensure: EnsureDataSphereResult;
74
+ readonly rekey: RekeyReport;
75
+ /** Recovery file with the #data seed — persist this for the user. */
76
+ readonly updatedRecoveryFile: string;
77
+ }
78
+ type SphereName = "data" | "data-old" | "self" | "circle" | "public" | "root";
79
+ /**
80
+ * Idempotently add the `#data` sphere to a (possibly legacy) identity and
81
+ * re-publish its did.json. Accepts either a parsed recovery file or the raw
82
+ * recovery JSON text.
83
+ */
84
+ export declare function ensureDataSphere(owner: ParsedRecoveryFile | string, opts?: MigrateOptions): Promise<EnsureDataSphereResult>;
85
+ /**
86
+ * Re-wrap every collection's CMK from its legacy sphere to the owner's `#data`
87
+ * key. Idempotent: collections already readable under `#data` are skipped.
88
+ * Requires that the recovery file carries a `#data` seed (run
89
+ * {@link ensureDataSphere} first, or pass {@link FullMigrationResult}).
90
+ */
91
+ export declare function rekeyLegacyCollections(owner: ParsedRecoveryFile | string, opts?: MigrateOptions): Promise<RekeyReport>;
92
+ /**
93
+ * Dual-read onboarding: KEEP each collection's legacy owner wrap and ADD a
94
+ * second owner wrap sealed to #data, so the existing app (reading under the
95
+ * legacy key) AND a #data client both decrypt the same data. Non-destructive.
96
+ * Thin wrapper over {@link rekeyLegacyCollections} with `mode: "add"`.
97
+ */
98
+ export declare function addDataSphereWrap(owner: ParsedRecoveryFile | string, opts?: MigrateOptions): Promise<RekeyReport>;
99
+ /**
100
+ * Run the full migration: ensureDataSphere then rekeyLegacyCollections.
101
+ * Returns the updated recovery file (with the #data seed) — persist it.
102
+ */
103
+ export declare function migrateLegacyEthosToDataSphere(recoveryText: string, opts?: MigrateOptions): Promise<FullMigrationResult>;
104
+ export {};
105
+ //# sourceMappingURL=migrate.d.ts.map
@@ -0,0 +1,367 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright 2026 Mathieu Colla
3
+ /**
4
+ * Legacy `#data` sphere migration.
5
+ *
6
+ * Identities created before the dedicated `#data` sphere landed
7
+ * (spec/data/02-key-hierarchy.md) have only root/public/circle/self keys and
8
+ * therefore:
9
+ * 1. their published did.json has no `#data` / `#data-kex` entry, and
10
+ * 2. their existing data collections' CMK is wrapped to the X25519 key
11
+ * derived from whatever sphere seed the *old* app passed to
12
+ * `createDataClient({ sphereSeed })` (in practice `#self`/`#public`), even
13
+ * though the wrap is LABELLED `${did}#data-kex` (the label is hard-coded
14
+ * in the SDK regardless of the seed).
15
+ *
16
+ * This module performs a two-step, idempotent, owner-only migration so the
17
+ * account converges on the protocol-intended `#data` convention:
18
+ *
19
+ * A. {@link ensureDataSphere} — generate a `#data` keypair (if absent),
20
+ * re-publish the did.json with the appended `#data` + `#data-kex` entries
21
+ * (root-signed) via the additive `aithos.augment_identity` primitive.
22
+ *
23
+ * B. {@link rekeyLegacyCollections} — for each collection, unwrap the CMK
24
+ * with the (auto-discovered) legacy sphere seed, re-wrap the SAME CMK to
25
+ * the `#data` X25519 key (label unchanged: `${did}#data-kex`), and persist
26
+ * via the existing `aithos.data.rotate_cmk` primitive. The CMK value is
27
+ * unchanged, so per-record DEKs stay valid — no record rewrap. Delegate
28
+ * wraps are preserved.
29
+ *
30
+ * Guardrails: dry-run mode, a pre-mutation backup of every `cmk_envelope`, a
31
+ * LOCAL round-trip verification (the freshly built `#data` wrap is unwrapped
32
+ * with the data seed and checked byte-equal to the CMK) BEFORE any server
33
+ * write, and idempotence (a collection already readable under `#data` is left
34
+ * untouched).
35
+ *
36
+ * Nothing here mutates any existing handler or primitive: A uses the new
37
+ * additive `aithos.augment_identity`, B reuses the existing `rotate_cmk`.
38
+ */
39
+ import * as ed from "@noble/ed25519";
40
+ import { sha512 } from "@noble/hashes/sha2.js";
41
+ import { browserIdentityFromStored, signedDidDocument, } from "@aithos/protocol-client";
42
+ import { DEFAULT_SDK_ENDPOINTS } from "./endpoints.js";
43
+ import { DEFAULT_API_BASE_URL } from "./auth.js";
44
+ import { signOwnerEnvelope } from "./internal/envelope.js";
45
+ import { parseRecoveryFile, serializeRecoveryFile, } from "./internal/recovery-file.js";
46
+ import { ed25519SeedToX25519PrivateKey, ed25519SeedToX25519PublicKey, tryUnwrapCmk, wrapCmkForRecipient, } from "./internal/cmk-wrap.js";
47
+ // noble/ed25519 v2 needs a sync sha512 for sync sign.
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
+ ed.etc.sha512Sync = (...m) =>
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ sha512(ed.etc.concatBytes(...m));
52
+ /* -------------------------------------------------------------------------- */
53
+ /* A — ensureDataSphere */
54
+ /* -------------------------------------------------------------------------- */
55
+ /**
56
+ * Idempotently add the `#data` sphere to a (possibly legacy) identity and
57
+ * re-publish its did.json. Accepts either a parsed recovery file or the raw
58
+ * recovery JSON text.
59
+ */
60
+ export async function ensureDataSphere(owner, opts = {}) {
61
+ const rec = typeof owner === "string" ? parseRecoveryFile(owner) : owner;
62
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
63
+ const apiBaseUrl = (opts.apiBaseUrl ?? DEFAULT_API_BASE_URL).replace(/\/$/, "");
64
+ opts.onProgress?.({ phase: "ensure-data-sphere", status: "start" });
65
+ // Generate a #data seed if the recovery file doesn't carry one yet.
66
+ const hadData = typeof rec.seedsHex.data === "string";
67
+ const dataSeedHex = hadData ? rec.seedsHex.data : bytesToHex(randomSeed32());
68
+ // Rebuild the full identity (incl. #data) and produce a root-signed did.json
69
+ // that carries the appended #data + #data-kex entries.
70
+ const identity = browserIdentityFromStored({
71
+ handle: rec.handle,
72
+ displayName: rec.displayName,
73
+ did: rec.did,
74
+ seeds: {
75
+ root: rec.seedsHex.root,
76
+ public: rec.seedsHex.public,
77
+ circle: rec.seedsHex.circle,
78
+ self: rec.seedsHex.self,
79
+ data: dataSeedHex,
80
+ },
81
+ });
82
+ const signedDoc = signedDidDocument(identity);
83
+ // Publish via the additive augment_identity primitive (idempotent server-side).
84
+ const rootSeed = hexToBytes(rec.seedsHex.root);
85
+ const url = `${apiBaseUrl}/mcp/primitives/write`;
86
+ const params = {
87
+ did_document: signedDoc,
88
+ handle: rec.handle,
89
+ display_name: rec.displayName,
90
+ };
91
+ const res = await jsonRpc(fetchImpl, url, "aithos.augment_identity", params, {
92
+ iss: rec.did,
93
+ aud: url,
94
+ verificationMethod: `${rec.did}#root`,
95
+ signSeed: rootSeed,
96
+ });
97
+ const added = res.data_sphere_added ?? !hadData;
98
+ // Merge the #data seed into a recovery file the caller can persist.
99
+ const updatedRecoveryFile = serializeRecoveryWithData(rec, dataSeedHex);
100
+ opts.onProgress?.({
101
+ phase: "ensure-data-sphere",
102
+ status: added ? "added" : "already-present",
103
+ });
104
+ return { did: rec.did, added, dataSeedHex, updatedRecoveryFile };
105
+ }
106
+ /* -------------------------------------------------------------------------- */
107
+ /* B — rekeyLegacyCollections */
108
+ /* -------------------------------------------------------------------------- */
109
+ /**
110
+ * Re-wrap every collection's CMK from its legacy sphere to the owner's `#data`
111
+ * key. Idempotent: collections already readable under `#data` are skipped.
112
+ * Requires that the recovery file carries a `#data` seed (run
113
+ * {@link ensureDataSphere} first, or pass {@link FullMigrationResult}).
114
+ */
115
+ export async function rekeyLegacyCollections(owner, opts = {}) {
116
+ const rec = typeof owner === "string" ? parseRecoveryFile(owner) : owner;
117
+ if (!rec.seedsHex.data && !opts.targetDataSeedHex) {
118
+ throw new Error("rekeyLegacyCollections: recovery file has no #data seed. Run ensureDataSphere first and persist its updatedRecoveryFile (or pass targetDataSeedHex for a rotation).");
119
+ }
120
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
121
+ const pdsUrl = (opts.pdsUrl ?? DEFAULT_SDK_ENDPOINTS.pds).replace(/\/$/, "");
122
+ const dryRun = opts.dryRun ?? false;
123
+ const mode = opts.mode ?? "replace";
124
+ const did = rec.did;
125
+ const rootSeed = hexToBytes(rec.seedsHex.root);
126
+ const dataKexRecipient = `${did}#data-kex`;
127
+ // Target #data key the wraps will be sealed TO. Defaults to the recovery's
128
+ // own #data; rotateEthos passes a fresh one (rotation).
129
+ const targetDataSeed = opts.targetDataSeedHex
130
+ ? hexToBytes(opts.targetDataSeedHex)
131
+ : hexToBytes(rec.seedsHex.data); // guard above guarantees one of the two
132
+ const dataPub = ed25519SeedToX25519PublicKey(targetDataSeed);
133
+ const dataPriv = ed25519SeedToX25519PrivateKey(targetDataSeed);
134
+ // Candidate seeds to UNWRAP an existing owner wrap with (auto-discovery). The
135
+ // target #data key is NOT in this list — "already sealed to target" is
136
+ // detected separately via `dataPriv`. When rotating (target ≠ recovery
137
+ // #data), the recovery's OLD #data is a valid unwrap key, included as
138
+ // "data-old".
139
+ const candidates = [
140
+ { name: "self", priv: ed25519SeedToX25519PrivateKey(hexToBytes(rec.seedsHex.self)) },
141
+ { name: "circle", priv: ed25519SeedToX25519PrivateKey(hexToBytes(rec.seedsHex.circle)) },
142
+ { name: "public", priv: ed25519SeedToX25519PrivateKey(hexToBytes(rec.seedsHex.public)) },
143
+ { name: "root", priv: ed25519SeedToX25519PrivateKey(rootSeed) },
144
+ ...(opts.targetDataSeedHex && rec.seedsHex.data
145
+ ? [{ name: "data-old", priv: ed25519SeedToX25519PrivateKey(hexToBytes(rec.seedsHex.data)) }]
146
+ : []),
147
+ ];
148
+ const readUrl = `${pdsUrl}/mcp/primitives/read`;
149
+ const writeUrl = `${pdsUrl}/mcp/primitives/write`;
150
+ const signRoot = {
151
+ iss: did,
152
+ verificationMethod: `${did}#root`,
153
+ signSeed: rootSeed,
154
+ };
155
+ // 1. Enumerate collections.
156
+ const listed = (await jsonRpc(fetchImpl, readUrl, "aithos.data.list_collections", { subject_did: did }, { ...signRoot, aud: readUrl }));
157
+ const names = (listed.items ?? []).map((c) => c.name);
158
+ const collections = [];
159
+ for (const name of names) {
160
+ const entry = await rekeyOne({
161
+ fetchImpl,
162
+ readUrl,
163
+ writeUrl,
164
+ signRoot,
165
+ did,
166
+ name,
167
+ candidates,
168
+ dataPub,
169
+ dataPriv,
170
+ dataKexRecipient,
171
+ dryRun,
172
+ mode,
173
+ });
174
+ collections.push(entry);
175
+ opts.onProgress?.({ phase: "rekey", status: "collection", collection: name, result: entry.status });
176
+ }
177
+ return { did, dryRun, collections };
178
+ }
179
+ /**
180
+ * Dual-read onboarding: KEEP each collection's legacy owner wrap and ADD a
181
+ * second owner wrap sealed to #data, so the existing app (reading under the
182
+ * legacy key) AND a #data client both decrypt the same data. Non-destructive.
183
+ * Thin wrapper over {@link rekeyLegacyCollections} with `mode: "add"`.
184
+ */
185
+ export async function addDataSphereWrap(owner, opts = {}) {
186
+ return rekeyLegacyCollections(owner, { ...opts, mode: "add" });
187
+ }
188
+ async function rekeyOne(a) {
189
+ const meta = (await jsonRpc(a.fetchImpl, a.readUrl, "aithos.data.get_collection", { subject_did: a.did, collection_name: a.name }, { ...a.signRoot, aud: a.readUrl }));
190
+ const urn = meta.urn ?? `urn:aithos:collection:${a.did}:${a.name}`;
191
+ const env = meta.cmk_envelope;
192
+ if (!env || !Array.isArray(env.wraps)) {
193
+ return { name: a.name, urn, status: "skipped-no-owner-wrap" };
194
+ }
195
+ // Owner wraps carry the label ${did}#data-kex (delegate wraps use a
196
+ // did:key:...#data-kex recipient). After a dual-read "add" there can be more
197
+ // than one owner wrap (legacy-sealed + #data-sealed).
198
+ const ownerWraps = env.wraps.filter((w) => w.recipient === a.dataKexRecipient);
199
+ if (ownerWraps.length === 0) {
200
+ return { name: a.name, urn, status: "skipped-no-owner-wrap", backupEnvelope: env };
201
+ }
202
+ const dataPriv = a.dataPriv; // private key of the TARGET #data
203
+ // Idempotence: is any owner wrap ALREADY readable under the target #data?
204
+ const alreadyData = ownerWraps.some((w) => tryUnwrapCmk({ wrap: w, collectionUrn: urn, privateKey: dataPriv }) !== null);
205
+ if (alreadyData) {
206
+ // add mode: a #data reader already exists → nothing to do.
207
+ // replace mode: if the SOLE owner wrap is #data we're done; if a legacy
208
+ // wrap also lingers we still consider it "already-data" (a #data reader
209
+ // exists) and leave it — replace's job is to *enable* #data, already true.
210
+ return { name: a.name, urn, status: "already-data", unwrappedWith: "data" };
211
+ }
212
+ // Auto-discover the legacy seed that sealed an owner wrap (try each wrap with
213
+ // each non-#data candidate). #data already excluded by the check above.
214
+ let cmk = null;
215
+ let usedSeed;
216
+ outer: for (const w of ownerWraps) {
217
+ for (const cand of a.candidates) {
218
+ const out = tryUnwrapCmk({ wrap: w, collectionUrn: urn, privateKey: cand.priv });
219
+ if (out) {
220
+ cmk = out;
221
+ usedSeed = cand.name;
222
+ break outer;
223
+ }
224
+ }
225
+ }
226
+ if (!cmk || !usedSeed) {
227
+ // Sealed to a key we don't hold (app-derived / foreign) — do NOT touch it.
228
+ return { name: a.name, urn, status: "unwrap-failed", backupEnvelope: env };
229
+ }
230
+ // Build the #data wrap of the SAME CMK (recipient label unchanged).
231
+ const dataWrap = wrapCmkForRecipient({
232
+ cmk,
233
+ recipientPublicKey: a.dataPub,
234
+ recipientDidUrl: a.dataKexRecipient,
235
+ collectionUrn: urn,
236
+ });
237
+ // GUARDRAIL: verify the new #data wrap round-trips to EXACTLY this CMK before
238
+ // any server write.
239
+ const verify = tryUnwrapCmk({ wrap: dataWrap, collectionUrn: urn, privateKey: dataPriv });
240
+ if (!verify || !bytesEqual(verify, cmk)) {
241
+ return { name: a.name, urn, status: "unwrap-failed", backupEnvelope: env };
242
+ }
243
+ let newEnvelope;
244
+ let delegateWrapsPreserved;
245
+ if (a.mode === "add") {
246
+ // Keep EVERYTHING (legacy owner wrap + delegate wraps) and append #data.
247
+ newEnvelope = { alg: env.alg, wraps: [...env.wraps, dataWrap] };
248
+ delegateWrapsPreserved = env.wraps.filter((w) => w.recipient !== a.dataKexRecipient).length;
249
+ }
250
+ else {
251
+ // replace: drop the legacy owner wrap(s), keep delegates, set #data as the
252
+ // sole owner wrap.
253
+ const delegateWraps = env.wraps.filter((w) => w.recipient !== a.dataKexRecipient);
254
+ newEnvelope = { alg: env.alg, wraps: [dataWrap, ...delegateWraps] };
255
+ delegateWrapsPreserved = delegateWraps.length;
256
+ }
257
+ if (a.dryRun) {
258
+ return { name: a.name, urn, status: "planned", unwrappedWith: usedSeed, delegateWrapsPreserved, backupEnvelope: env };
259
+ }
260
+ await jsonRpc(a.fetchImpl, a.writeUrl, "aithos.data.rotate_cmk", { collection_urn: urn, new_cmk_envelope: newEnvelope }, { ...a.signRoot, aud: a.writeUrl });
261
+ return {
262
+ name: a.name,
263
+ urn,
264
+ status: a.mode === "add" ? "data-wrap-added" : "rekeyed",
265
+ unwrappedWith: usedSeed,
266
+ delegateWrapsPreserved,
267
+ backupEnvelope: env,
268
+ };
269
+ }
270
+ /* -------------------------------------------------------------------------- */
271
+ /* Combined helper */
272
+ /* -------------------------------------------------------------------------- */
273
+ /**
274
+ * Run the full migration: ensureDataSphere then rekeyLegacyCollections.
275
+ * Returns the updated recovery file (with the #data seed) — persist it.
276
+ */
277
+ export async function migrateLegacyEthosToDataSphere(recoveryText, opts = {}) {
278
+ const ensure = await ensureDataSphere(recoveryText, opts);
279
+ // Re-parse with the freshly merged #data seed so rekey has it.
280
+ const recWithData = parseRecoveryFile(ensure.updatedRecoveryFile);
281
+ const rekey = await rekeyLegacyCollections(recWithData, opts);
282
+ return { ensure, rekey, updatedRecoveryFile: ensure.updatedRecoveryFile };
283
+ }
284
+ async function jsonRpc(fetchImpl, url, method, params, ctx) {
285
+ const envelope = await signOwnerEnvelope({
286
+ iss: ctx.iss,
287
+ aud: ctx.aud,
288
+ method,
289
+ params,
290
+ verificationMethod: ctx.verificationMethod,
291
+ signer: { sign: async (msg) => ed.sign(msg, ctx.signSeed) },
292
+ });
293
+ const body = {
294
+ jsonrpc: "2.0",
295
+ id: `migrate_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
296
+ method,
297
+ params: { ...params, _envelope: envelope },
298
+ };
299
+ const r = await fetchImpl(url, {
300
+ method: "POST",
301
+ headers: { "content-type": "application/json" },
302
+ body: JSON.stringify(body),
303
+ });
304
+ const json = (await r.json());
305
+ if (json.error) {
306
+ const err = new Error(`${method} failed: ${json.error.message}`);
307
+ err.code = json.error.code;
308
+ err.data = json.error.data;
309
+ throw err;
310
+ }
311
+ return json.result ?? {};
312
+ }
313
+ /* -------------------------------------------------------------------------- */
314
+ /* Small utils */
315
+ /* -------------------------------------------------------------------------- */
316
+ function randomSeed32() {
317
+ const buf = new Uint8Array(32);
318
+ globalThis.crypto?.getRandomValues(buf);
319
+ return buf;
320
+ }
321
+ function hexToBytes(hex) {
322
+ if (hex.length % 2 !== 0)
323
+ throw new Error("hex must be even-length");
324
+ const out = new Uint8Array(hex.length / 2);
325
+ for (let i = 0; i < out.length; i++)
326
+ out[i] = parseInt(hex.substr(i * 2, 2), 16);
327
+ return out;
328
+ }
329
+ function bytesToHex(b) {
330
+ let out = "";
331
+ for (let i = 0; i < b.length; i++)
332
+ out += b[i].toString(16).padStart(2, "0");
333
+ return out;
334
+ }
335
+ function bytesEqual(a, b) {
336
+ if (a.length !== b.length)
337
+ return false;
338
+ let diff = 0;
339
+ for (let i = 0; i < a.length; i++)
340
+ diff |= a[i] ^ b[i];
341
+ return diff === 0;
342
+ }
343
+ /**
344
+ * Serialize a recovery file (SDK shape) with the #data seed merged in. We don't
345
+ * have a BrowserIdentity here, so we build the canonical SDK shape directly —
346
+ * matching `serializeRecoveryFile`'s output shape (used elsewhere for the
347
+ * round-trip parse test).
348
+ */
349
+ function serializeRecoveryWithData(rec, dataSeedHex) {
350
+ void serializeRecoveryFile; // referenced for shape parity; we emit the same fields
351
+ const payload = {
352
+ aithos_recovery_version: "0.1.0-plaintext",
353
+ handle: rec.handle,
354
+ display_name: rec.displayName,
355
+ did: rec.did,
356
+ seeds_hex: {
357
+ root: rec.seedsHex.root,
358
+ public: rec.seedsHex.public,
359
+ circle: rec.seedsHex.circle,
360
+ self: rec.seedsHex.self,
361
+ data: dataSeedHex,
362
+ },
363
+ saved_at: new Date().toISOString(),
364
+ };
365
+ return JSON.stringify(payload, null, 2);
366
+ }
367
+ //# sourceMappingURL=migrate.js.map
@@ -0,0 +1,94 @@
1
+ /** Raw 32-byte seeds for a rotation. `root` is unchanged; the rest are new. */
2
+ export interface RotationSeeds {
3
+ readonly root: Uint8Array;
4
+ readonly public: Uint8Array;
5
+ readonly circle: Uint8Array;
6
+ readonly self: Uint8Array;
7
+ /** Optional dedicated #data sphere (added if present). */
8
+ readonly data?: Uint8Array;
9
+ }
10
+ interface VerificationMethodLike {
11
+ id: string;
12
+ type: "Ed25519VerificationKey2020" | "X25519KeyAgreementKey2020";
13
+ controller: string;
14
+ publicKeyMultibase: string;
15
+ }
16
+ /** Minimal structural view of a did.json (browser-safe; no node import). */
17
+ export interface DidDocumentLike {
18
+ "@context": readonly string[];
19
+ id: string;
20
+ verificationMethod: readonly VerificationMethodLike[];
21
+ keyAgreement?: readonly VerificationMethodLike[];
22
+ aithos: {
23
+ version: "0.1.0";
24
+ display_name?: string;
25
+ created_at: string;
26
+ rotated: readonly unknown[];
27
+ };
28
+ proof?: unknown;
29
+ }
30
+ /**
31
+ * Build + sign a rotated did.json. The result rotates every Ethos sphere whose
32
+ * seed differs from the previously-published key, appends one `rotated[]` entry
33
+ * per changed sphere (preserving prior history), carries / adds `#data`, and is
34
+ * signed by `#root`. Ready to POST via `aithos.rotate_sphere_key`.
35
+ *
36
+ * @param seeds the post-rotation seeds (same root, new sphere seeds).
37
+ * @param previousDoc the currently-published did.json (old pubkeys + history).
38
+ * @param displayName display name to carry on the doc.
39
+ * @param reason audit reason recorded on each new rotated entry.
40
+ */
41
+ export declare function buildSignedRotatedDidDocument(seeds: RotationSeeds, previousDoc: DidDocumentLike, displayName: string, reason?: string): DidDocumentLike;
42
+ import { type RekeyReport } from "./migrate.js";
43
+ import { type ParsedRecoveryFile } from "./internal/recovery-file.js";
44
+ export interface RotateEthosOptions {
45
+ /** api.aithos.be base URL. Default {@link DEFAULT_API_BASE_URL}. NOTE: the
46
+ * Ethos-zone recopy uses @aithos/protocol-client's ambient endpoint
47
+ * (api.aithos.be); a non-default apiBaseUrl only redirects the did.json
48
+ * rotation, not the zone publish — keep them aligned (prod) for now. */
49
+ readonly apiBaseUrl?: string;
50
+ /** PDS base URL for the collection re-key. Default `https://pds.aithos.be`. */
51
+ readonly pdsUrl?: string;
52
+ readonly fetch?: typeof fetch;
53
+ /** Audit reason recorded in rotated[]. */
54
+ readonly reason?: string;
55
+ /**
56
+ * Skip the Ethos circle/self zone recopy (the live, protocol-client-RPC part).
57
+ * Used by hermetic tests and by data-only accounts. WARNING: with real
58
+ * circle/self content, skipping while rotating the sphere keys would orphan
59
+ * that content — rotateEthos refuses to rotate the Ethos spheres when an
60
+ * edition exists and zones are skipped (unless `force`).
61
+ */
62
+ readonly skipZones?: boolean;
63
+ /** Override the safety refusal above. */
64
+ readonly force?: boolean;
65
+ readonly onProgress?: (msg: string) => void;
66
+ }
67
+ export interface RotateEthosResult {
68
+ readonly did: string;
69
+ /** Ethos spheres whose key changed (always public/circle/self here). */
70
+ readonly rotatedSpheres: readonly string[];
71
+ /** Whether a fresh edition was republished (zones re-sealed under new keys). */
72
+ readonly editionRepublished: boolean;
73
+ /** Whether the account had an Ethos edition at all. */
74
+ readonly hadEdition: boolean;
75
+ /** Collection re-key report (dual #data wraps toward the NEW #data). */
76
+ readonly collections: RekeyReport;
77
+ /** New recovery file (ALL new sphere seeds + new #data). PERSIST THIS. */
78
+ readonly updatedRecoveryFile: string;
79
+ }
80
+ /**
81
+ * Fully rotate an Ethos: new public/circle/self/#data keys (root unchanged),
82
+ * re-encrypt the Ethos zones under the new keys (fresh edition), and dual-wrap
83
+ * every data collection toward the new #data. Entirely client-side; uses only
84
+ * existing API primitives (rotate_sphere_key, publish_ethos_edition via the
85
+ * protocol-client editor, rotate_cmk). Returns a new recovery file — persist it.
86
+ *
87
+ * Option (a) semantics: the new head edition (carrying all current content) is
88
+ * fully verifiable under the new keys; pre-rotation edition snapshots are not
89
+ * re-verifiable against the rotated did.json (rotated[] retains the old keys so
90
+ * a rotated[]-aware verifier can be added later without re-rotating).
91
+ */
92
+ export declare function rotateEthos(recovery: ParsedRecoveryFile | string, opts?: RotateEthosOptions): Promise<RotateEthosResult>;
93
+ export {};
94
+ //# sourceMappingURL=rotate.d.ts.map