@aithos/sdk 0.1.0-alpha.53 → 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/compute.d.ts +98 -0
- package/dist/src/compute.js +47 -0
- package/dist/src/data.d.ts +28 -7
- package/dist/src/data.js +85 -12
- package/dist/src/index.d.ts +5 -3
- 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/src/sdk.d.ts +26 -2
- package/dist/src/sdk.js +45 -1
- package/dist/test/converse.test.d.ts +2 -0
- package/dist/test/converse.test.js +162 -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,132 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/**
|
|
4
|
+
* CMK wrap / unwrap primitives for the data sub-protocol.
|
|
5
|
+
*
|
|
6
|
+
* These are a CANONICAL, byte-identical copy of the private crypto used by
|
|
7
|
+
* `DataClientImpl` in `../data.ts` (`#wrapCmkForRecipient` / `#unwrapCmk` and
|
|
8
|
+
* the X25519-seed derivation). They are factored out here so the legacy
|
|
9
|
+
* #data-sphere migration (`../migrate.ts`) can re-wrap a collection's CMK to
|
|
10
|
+
* the owner's `#data` key WITHOUT going through the high-level `DataClient`
|
|
11
|
+
* (which is hard-wired to a single sphere seed and so cannot unwrap a wrap
|
|
12
|
+
* sealed to a *different* legacy sphere).
|
|
13
|
+
*
|
|
14
|
+
* INVARIANT: the wire format produced here MUST stay byte-identical to
|
|
15
|
+
* `data.ts`. `test/cmk-wrap-conformance.test.ts` proves it by cross-unwrapping
|
|
16
|
+
* (data.ts wraps → cmk-wrap unwraps, and vice-versa). If you change one, change
|
|
17
|
+
* both and keep that test green — a drift here silently corrupts migrated data.
|
|
18
|
+
*/
|
|
19
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
20
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
21
|
+
import { sha256, sha512 } from "@noble/hashes/sha2.js";
|
|
22
|
+
import { XChaCha20Poly1305 } from "@stablelib/xchacha20poly1305";
|
|
23
|
+
/* -------------------------------------------------------------------------- */
|
|
24
|
+
/* X25519 seed derivation (mirrors data.ts) */
|
|
25
|
+
/* -------------------------------------------------------------------------- */
|
|
26
|
+
/**
|
|
27
|
+
* Derive a 32-byte X25519 private key from an Ed25519 seed via SHA-512
|
|
28
|
+
* truncation + clamping (libsodium crypto_sign_ed25519_sk_to_curve25519).
|
|
29
|
+
*/
|
|
30
|
+
export function ed25519SeedToX25519PrivateKey(seed) {
|
|
31
|
+
if (seed.length !== 32)
|
|
32
|
+
throw new Error("Ed25519 seed must be 32 bytes");
|
|
33
|
+
const h = sha512(seed);
|
|
34
|
+
const sk = new Uint8Array(h.slice(0, 32));
|
|
35
|
+
sk[0] = sk[0] & 248;
|
|
36
|
+
sk[31] = sk[31] & 127;
|
|
37
|
+
sk[31] = sk[31] | 64;
|
|
38
|
+
return sk;
|
|
39
|
+
}
|
|
40
|
+
export function ed25519SeedToX25519PublicKey(seed) {
|
|
41
|
+
const sk = ed25519SeedToX25519PrivateKey(seed);
|
|
42
|
+
try {
|
|
43
|
+
return x25519.getPublicKey(sk);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
sk.fill(0);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/* -------------------------------------------------------------------------- */
|
|
50
|
+
/* Wrap / unwrap (mirrors data.ts #wrapCmkForRecipient / #unwrapCmk) */
|
|
51
|
+
/* -------------------------------------------------------------------------- */
|
|
52
|
+
export function wrapCmkForRecipient(args) {
|
|
53
|
+
const ephSk = x25519.utils.randomSecretKey();
|
|
54
|
+
const ephPk = x25519.getPublicKey(ephSk);
|
|
55
|
+
const shared = x25519.getSharedSecret(ephSk, args.recipientPublicKey);
|
|
56
|
+
const wrapKey = hkdf(sha256, shared, utf8("aithos-data-cmk-wrap-v1"), utf8(args.recipientDidUrl), 32);
|
|
57
|
+
const wrapNonce = randomBytes24();
|
|
58
|
+
const aad = aadCmkWrap(args.collectionUrn, args.recipientDidUrl);
|
|
59
|
+
const aead = new XChaCha20Poly1305(wrapKey);
|
|
60
|
+
const wrapped = aead.seal(wrapNonce, args.cmk, aad);
|
|
61
|
+
wrapKey.fill(0);
|
|
62
|
+
shared.fill(0);
|
|
63
|
+
return {
|
|
64
|
+
recipient: args.recipientDidUrl,
|
|
65
|
+
alg: "x25519-hkdf-sha256-aead",
|
|
66
|
+
ephemeral_public: base64Std(ephPk),
|
|
67
|
+
wrap_nonce: base64Std(wrapNonce),
|
|
68
|
+
wrapped_key: base64Std(wrapped),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Try to unwrap the CMK from `wrap` with `privateKey`. Returns the CMK bytes
|
|
73
|
+
* on success, or `null` when the AEAD tag fails (wrong key / AAD mismatch) —
|
|
74
|
+
* the migration relies on this null-return to AUTO-DISCOVER which legacy sphere
|
|
75
|
+
* seed sealed the wrap (try each, the right one verifies).
|
|
76
|
+
*/
|
|
77
|
+
export function tryUnwrapCmk(args) {
|
|
78
|
+
const ephPk = fromBase64(args.wrap.ephemeral_public);
|
|
79
|
+
const wrapNonce = fromBase64(args.wrap.wrap_nonce);
|
|
80
|
+
const wrappedKey = fromBase64(args.wrap.wrapped_key);
|
|
81
|
+
const shared = x25519.getSharedSecret(args.privateKey, ephPk);
|
|
82
|
+
const wrapKey = hkdf(sha256, shared, utf8("aithos-data-cmk-wrap-v1"), utf8(args.wrap.recipient), 32);
|
|
83
|
+
const aad = aadCmkWrap(args.collectionUrn, args.wrap.recipient);
|
|
84
|
+
const aead = new XChaCha20Poly1305(wrapKey);
|
|
85
|
+
const cmk = aead.open(wrapNonce, wrappedKey, aad);
|
|
86
|
+
wrapKey.fill(0);
|
|
87
|
+
shared.fill(0);
|
|
88
|
+
return cmk ?? null;
|
|
89
|
+
}
|
|
90
|
+
/* -------------------------------------------------------------------------- */
|
|
91
|
+
/* Local helpers (byte-identical to data.ts) */
|
|
92
|
+
/* -------------------------------------------------------------------------- */
|
|
93
|
+
function aadCmkWrap(collectionUrn, recipient) {
|
|
94
|
+
const p = utf8("aithos-data-cmk-v1\0");
|
|
95
|
+
const c = utf8(collectionUrn);
|
|
96
|
+
const sep = new Uint8Array([0]);
|
|
97
|
+
const r = utf8(recipient);
|
|
98
|
+
const out = new Uint8Array(p.length + c.length + sep.length + r.length);
|
|
99
|
+
let off = 0;
|
|
100
|
+
out.set(p, off);
|
|
101
|
+
off += p.length;
|
|
102
|
+
out.set(c, off);
|
|
103
|
+
off += c.length;
|
|
104
|
+
out.set(sep, off);
|
|
105
|
+
off += sep.length;
|
|
106
|
+
out.set(r, off);
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
function utf8(s) {
|
|
110
|
+
return new TextEncoder().encode(s);
|
|
111
|
+
}
|
|
112
|
+
function randomBytes24() {
|
|
113
|
+
const buf = new Uint8Array(24);
|
|
114
|
+
globalThis.crypto?.getRandomValues(buf);
|
|
115
|
+
return buf;
|
|
116
|
+
}
|
|
117
|
+
function base64Std(bytes) {
|
|
118
|
+
let bin = "";
|
|
119
|
+
for (let i = 0; i < bytes.length; i++)
|
|
120
|
+
bin += String.fromCharCode(bytes[i]);
|
|
121
|
+
return btoa(bin);
|
|
122
|
+
}
|
|
123
|
+
function fromBase64(s) {
|
|
124
|
+
const std = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
125
|
+
const padded = std + "=".repeat((4 - (std.length % 4)) % 4);
|
|
126
|
+
const bin = atob(padded);
|
|
127
|
+
const out = new Uint8Array(bin.length);
|
|
128
|
+
for (let i = 0; i < bin.length; i++)
|
|
129
|
+
out[i] = bin.charCodeAt(i);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=cmk-wrap.js.map
|
package/dist/src/key-store.d.ts
CHANGED
|
@@ -20,9 +20,13 @@ export interface StoredOwnerKeys {
|
|
|
20
20
|
readonly circle: string;
|
|
21
21
|
readonly self: string;
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
23
|
+
* Dedicated `#data` sphere seed (spec/data/02-key-hierarchy.md) — the key
|
|
24
|
+
* that signs ALL data/asset PDS operations (create, edit, manage
|
|
25
|
+
* collections), keeping the root cold. Present on owners created since the
|
|
26
|
+
* `#data` sphere landed. Absent only on legacy owners, which predate it and
|
|
27
|
+
* therefore have no dedicated PDS key — migrate them to `#data` (the backend
|
|
28
|
+
* does this lazily on next custodial sign-in; self-custody/SSO already carry
|
|
29
|
+
* it). Signing PDS ops under any other sphere is a legacy anti-pattern.
|
|
26
30
|
*/
|
|
27
31
|
readonly data?: string;
|
|
28
32
|
};
|
|
@@ -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
|