@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.
- 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 +85 -12
- 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 +136 -0
- package/package.json +1 -1
|
@@ -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
|