@calimero-network/mero-js 29.3.0 → 29.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -135,20 +135,20 @@ async function x25519KeyPair(secret) {
135
135
  const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
136
136
  return { privateKey: pair.privateKey, publicKey };
137
137
  }
138
- const privateKey = await crypto.subtle.importKey(
138
+ const privateKey2 = await crypto.subtle.importKey(
139
139
  "pkcs8",
140
140
  concat(PKCS8_X25519_PREFIX, secret),
141
141
  { name: "X25519" },
142
142
  true,
143
143
  ["deriveBits"]
144
144
  );
145
- const jwk = await crypto.subtle.exportKey("jwk", privateKey);
146
- return { privateKey, publicKey: base64UrlDecode(jwk.x) };
145
+ const jwk = await crypto.subtle.exportKey("jwk", privateKey2);
146
+ return { privateKey: privateKey2, publicKey: base64UrlDecode(jwk.x) };
147
147
  }
148
- async function dh(privateKey, publicKey) {
148
+ async function dh(privateKey2, publicKey) {
149
149
  const peer = await crypto.subtle.importKey("raw", publicKey, { name: "X25519" }, false, []);
150
150
  return new Uint8Array(
151
- await crypto.subtle.deriveBits({ name: "X25519", public: peer }, privateKey, 256)
151
+ await crypto.subtle.deriveBits({ name: "X25519", public: peer }, privateKey2, 256)
152
152
  );
153
153
  }
154
154
  function noiseNonce(n) {
@@ -1418,6 +1418,19 @@ var OriginTaken = class extends CalimeroError {
1418
1418
  this.name = "OriginTaken";
1419
1419
  }
1420
1420
  };
1421
+ var RecoveryNotReady = class extends CalimeroError {
1422
+ constructor(message, details = {}) {
1423
+ super(409, "RECOVERY_NOT_READY", message, details);
1424
+ this.name = "RecoveryNotReady";
1425
+ this.readyAt = typeof details.readyAt === "number" ? details.readyAt : void 0;
1426
+ }
1427
+ };
1428
+ var WrongRecoveryCode = class extends CalimeroError {
1429
+ constructor(message, details = {}) {
1430
+ super(401, "UNAUTHENTICATED", message || "unknown or used recovery code", details);
1431
+ this.name = "WrongRecoveryCode";
1432
+ }
1433
+ };
1421
1434
  var SignUpRefused = class extends CalimeroError {
1422
1435
  constructor(message, details = {}) {
1423
1436
  super(403, "SIGN_UP_REFUSED", message, details);
@@ -1507,6 +1520,8 @@ function errorFrom(status, body, retryAfterHeader) {
1507
1520
  return new OriginTaken(message, details);
1508
1521
  case "429 QUOTA_EXCEEDED":
1509
1522
  return new QuotaExceeded(message, details);
1523
+ case "409 RECOVERY_NOT_READY":
1524
+ return new RecoveryNotReady(message, details);
1510
1525
  case "403 SIGN_UP_REFUSED":
1511
1526
  return new SignUpRefused(message, details);
1512
1527
  case "412 CONFIRM_REQUIRED":
@@ -1585,6 +1600,72 @@ function browserPasskey() {
1585
1600
  };
1586
1601
  }
1587
1602
 
1603
+ // src/cloud-v2/recovery.ts
1604
+ var RECOVERY_DOMAIN = "calimero.org-recovery.v1\0";
1605
+ var MAX_RECOVERY_NONCE_BYTES = 64;
1606
+ var PKCS8_ED25519 = Uint8Array.from([
1607
+ 48,
1608
+ 46,
1609
+ 2,
1610
+ 1,
1611
+ 0,
1612
+ 48,
1613
+ 5,
1614
+ 6,
1615
+ 3,
1616
+ 43,
1617
+ 101,
1618
+ 112,
1619
+ 4,
1620
+ 34,
1621
+ 4,
1622
+ 32
1623
+ ]);
1624
+ function newRecoverySeed() {
1625
+ return crypto.getRandomValues(new Uint8Array(32));
1626
+ }
1627
+ function recoverySeedText(seed) {
1628
+ checkSeed(seed);
1629
+ const hex2 = Array.from(seed, (b) => b.toString(16).padStart(2, "0")).join("");
1630
+ const groups = [];
1631
+ for (let i = 0; i < hex2.length; i += 8) groups.push(hex2.slice(i, i + 8));
1632
+ return groups.join("-");
1633
+ }
1634
+ function parseRecoverySeed(text) {
1635
+ const digits = text.replace(/[-\s]/g, "");
1636
+ if (!/^[0-9a-fA-F]{64}$/.test(digits)) throw new Error("a recovery seed is 64 hex digits");
1637
+ return Uint8Array.from({ length: 32 }, (_, i) => parseInt(digits.slice(2 * i, 2 * i + 2), 16));
1638
+ }
1639
+ function recoveryMessage(org, account, nonce2) {
1640
+ return new TextEncoder().encode(`${RECOVERY_DOMAIN}${org}\0${account}\0${nonce2}`);
1641
+ }
1642
+ async function recoveryPublicKey(seed) {
1643
+ const key = await privateKey(seed, true);
1644
+ const jwk = await crypto.subtle.exportKey("jwk", key);
1645
+ if (!jwk.x) throw new Error("this runtime cannot export an Ed25519 public key");
1646
+ return jwk.x;
1647
+ }
1648
+ async function signRecovery(seed, org, account, nonce2) {
1649
+ const nonceBytes = new TextEncoder().encode(nonce2).length;
1650
+ if (nonceBytes === 0 || nonceBytes > MAX_RECOVERY_NONCE_BYTES) {
1651
+ throw new Error(`a recovery nonce is 1 to ${MAX_RECOVERY_NONCE_BYTES} bytes`);
1652
+ }
1653
+ const key = await privateKey(seed, false);
1654
+ const sig = await crypto.subtle.sign("Ed25519", key, buf2(recoveryMessage(org, account, nonce2)));
1655
+ return b64url(new Uint8Array(sig));
1656
+ }
1657
+ function checkSeed(seed) {
1658
+ if (seed.length !== 32) throw new Error("a recovery seed is 32 bytes");
1659
+ }
1660
+ function privateKey(seed, extractable) {
1661
+ checkSeed(seed);
1662
+ const pkcs8 = new Uint8Array(PKCS8_ED25519.length + 32);
1663
+ pkcs8.set(PKCS8_ED25519);
1664
+ pkcs8.set(seed, PKCS8_ED25519.length);
1665
+ return crypto.subtle.importKey("pkcs8", buf2(pkcs8), { name: "Ed25519" }, extractable, ["sign"]);
1666
+ }
1667
+ var buf2 = (b) => b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
1668
+
1588
1669
  // src/cloud-v2/approvals.ts
1589
1670
  var MAX_REPORT_PAGE = 100;
1590
1671
  function pageQuery(page = {}) {
@@ -1787,8 +1868,9 @@ function retryDelayMs(retry, options) {
1787
1868
  return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
1788
1869
  }
1789
1870
  function ceremony(path) {
1790
- return path === "auth/refresh" || path.startsWith("auth/passkey/");
1871
+ return path === "auth/refresh" || path.startsWith("auth/passkey/") || RECOVERY_CEREMONIES.has(path);
1791
1872
  }
1873
+ var RECOVERY_CEREMONIES = /* @__PURE__ */ new Set(["auth/recovery/begin", "auth/recovery/start", "auth/recovery/finish"]);
1792
1874
  var CalimeroClient = class _CalimeroClient {
1793
1875
  constructor(options) {
1794
1876
  /** The one in use: an index into {@link nodeUrls}. */
@@ -2223,6 +2305,71 @@ var CalimeroClient = class _CalimeroClient {
2223
2305
  async labelPasskey(credentialId, label) {
2224
2306
  await this.post(`auth/passkeys/${seg(credentialId)}`, { label });
2225
2307
  }
2308
+ // ---------------------------------------------------------------- account recovery
2309
+ //
2310
+ // core #4844 (`docs/specs/account-recovery.md`): a recovery code, made with
2311
+ // a passkey and shown once, lets someone who lost every passkey register a
2312
+ // new one; it is added, and the account signed in, 72 hours later unless a
2313
+ // passkey the account still has cancels it first.
2314
+ /**
2315
+ * Make the account's recovery code (replacing any it had), approved with
2316
+ * `passkey` (`412 CONFIRM_REQUIRED`). Answers the code, once: show it to
2317
+ * the person to keep offline; the node keeps only its public key. A passkey
2318
+ * session on the cluster's own origin only (never an API key or a
2319
+ * package-bound session).
2320
+ */
2321
+ async createRecoveryCode(passkey) {
2322
+ const { code } = await this.postConfirmed("auth/recovery", {}, passkey);
2323
+ return code;
2324
+ }
2325
+ /** Remove the account's recovery code, approved with `passkey`. */
2326
+ async removeRecoveryCode(passkey) {
2327
+ await this.deleteConfirmed("auth/recovery", passkey);
2328
+ }
2329
+ /**
2330
+ * Signed out: start recovering the account `code` names. `passkey` makes a
2331
+ * new passkey for it (the browser's create prompt, user verified); the
2332
+ * node spends the code and keeps the passkey as pending. Nothing is added
2333
+ * and no session opens: finish with {@link finishAccountRecovery} at
2334
+ * `readyAt`. A code the cluster does not hold (mistyped, replaced or
2335
+ * spent) is {@link WrongRecoveryCode}; an account with a recovery already
2336
+ * under way `409 RECOVERY_PENDING`.
2337
+ */
2338
+ async startAccountRecovery(code, passkey) {
2339
+ try {
2340
+ const begin = await this.post("auth/recovery/begin", { code });
2341
+ const registration = await passkey.create(begin.options);
2342
+ return await this.post("auth/recovery/start", { challengeId: begin.challengeId, code, registration });
2343
+ } catch (error) {
2344
+ if (error instanceof LoginRequired) throw new WrongRecoveryCode(error.message.replace(/^401 UNAUTHENTICATED: /, ""), error.details);
2345
+ throw error;
2346
+ }
2347
+ }
2348
+ /**
2349
+ * Cancel the recovery of the signed-in account under way, approved with
2350
+ * `passkey` (one the account already has). The code stays spent: make a
2351
+ * new one.
2352
+ */
2353
+ async cancelAccountRecovery(passkey) {
2354
+ await this.postConfirmed("auth/recovery/cancel", {}, passkey);
2355
+ }
2356
+ /**
2357
+ * Signed out, once the delay passed: finish the recovery with the passkey
2358
+ * it registered (`passkey` signs the node's sign-in challenge). The passkey
2359
+ * joins the account, every other session of the account ends, and this
2360
+ * client is signed in. `accountId` (from the start) lets the node refuse
2361
+ * early and offer only that passkey; before `readyAt` it is
2362
+ * {@link RecoveryNotReady}, with no recovery under way {@link NotFound}.
2363
+ */
2364
+ async finishAccountRecovery(passkey, accountId) {
2365
+ const session = await this.postConfirmed(
2366
+ "auth/recovery/finish",
2367
+ accountId ? { accountId } : {},
2368
+ passkey
2369
+ );
2370
+ this.useSession(session);
2371
+ return session;
2372
+ }
2226
2373
  /** What this account uses (apps, bundle bytes, API keys, owned orgs), with the cluster's quotas. */
2227
2374
  usage() {
2228
2375
  return this.get("auth/usage");
@@ -2536,10 +2683,51 @@ var CalimeroClient = class _CalimeroClient {
2536
2683
  await this.delete(`auth/aliases/${seg(alias)}`);
2537
2684
  }
2538
2685
  // ---------------------------------------------------------------- organizations
2539
- async createOrg(name) {
2540
- const { orgId } = await this.post("orgs", { name });
2686
+ /**
2687
+ * Create an organization. `recoveryKey` (from {@link recoveryPublicKey}) lets
2688
+ * whoever holds its seed recover the organization if its owner loses every
2689
+ * passkey; keep the seed offline ({@link recoverySeedText}).
2690
+ */
2691
+ async createOrg(name, options = {}) {
2692
+ const body = { name };
2693
+ if (options.recoveryKey !== void 0) body.recoveryKey = options.recoveryKey;
2694
+ const { orgId } = await this.post("orgs", body);
2541
2695
  return orgId;
2542
2696
  }
2697
+ /**
2698
+ * Set or rotate (`publicKey`, from {@link recoveryPublicKey}) or remove
2699
+ * (`null`) the organization's recovery key: its owner only, approved with
2700
+ * `passkey`. A recovery under way is cancelled, and the old seed's
2701
+ * signatures are refused from then on.
2702
+ */
2703
+ async setRecoveryKey(org, publicKey, passkey) {
2704
+ await this.postConfirmed(`orgs/${seg(org)}/recovery`, { publicKey }, passkey);
2705
+ }
2706
+ /**
2707
+ * Start handing `org` to the signed-in account with the recovery seed,
2708
+ * which signs here and never leaves this process. Finish with
2709
+ * {@link finishRecovery} once `readyAt` has passed; until then the owner may
2710
+ * cancel. A wrong seed (or no organization, or none set) is a `403`.
2711
+ */
2712
+ async startRecovery(org, seed) {
2713
+ const account = (await this.me()).accountId;
2714
+ const nonce2 = b64url(crypto.getRandomValues(new Uint8Array(16)));
2715
+ const signature = await signRecovery(seed, org, account, nonce2);
2716
+ return this.post(`orgs/${seg(org)}/recovery/start`, { nonce: nonce2, signature });
2717
+ }
2718
+ /** Cancel the recovery under way: the organization's owner, approved with `passkey`. */
2719
+ async cancelRecovery(org, passkey) {
2720
+ await this.postConfirmed(`orgs/${seg(org)}/recovery/cancel`, {}, passkey);
2721
+ }
2722
+ /**
2723
+ * Finish a recovery this account started, once its delay passed
2724
+ * (`409 RECOVERY_NOT_READY` before, with `readyAt`), approved with this
2725
+ * account's `passkey`. The account becomes the owner; the previous owner
2726
+ * leaves the organization.
2727
+ */
2728
+ finishRecovery(org, passkey) {
2729
+ return this.postConfirmed(`orgs/${seg(org)}/recovery/finish`, {}, passkey);
2730
+ }
2543
2731
  /** The organization, with the teams this caller reaches or manages. */
2544
2732
  getOrg(org) {
2545
2733
  return this.get(`orgs/${seg(org)}`);
@@ -3386,6 +3574,7 @@ export {
3386
3574
  IdempotencyMismatch,
3387
3575
  KMS_RELEASE_SIGNER,
3388
3576
  LoginRequired,
3577
+ MAX_RECOVERY_NONCE_BYTES,
3389
3578
  MAX_REPORT_PAGE,
3390
3579
  MOCK_QUOTE_HEADER,
3391
3580
  NODE_RELEASE_SIGNER,
@@ -3395,7 +3584,9 @@ export {
3395
3584
  OriginTaken,
3396
3585
  PRODUCTION_PROFILE,
3397
3586
  QuotaExceeded,
3587
+ RECOVERY_DOMAIN,
3398
3588
  RELEASE_VERIFIER,
3589
+ RecoveryNotReady,
3399
3590
  ReleaseDeadlineError,
3400
3591
  SEALED_CONTENT_TYPE,
3401
3592
  SEALED_PATH,
@@ -3405,6 +3596,7 @@ export {
3405
3596
  TrapError,
3406
3597
  UnavailableError,
3407
3598
  WrongNode,
3599
+ WrongRecoveryCode,
3408
3600
  agentKeyBinding,
3409
3601
  agentQuoteVerifier,
3410
3602
  attestAgent,
@@ -3428,18 +3620,24 @@ export {
3428
3620
  fromB64url,
3429
3621
  frontendManifest,
3430
3622
  generateOwnerKey,
3623
+ newRecoverySeed,
3431
3624
  orgCapabilities,
3432
3625
  ownerPublicKey,
3433
3626
  pageDeployment,
3434
3627
  pageQuery,
3628
+ parseRecoverySeed,
3435
3629
  pendingApprovalsFrom,
3436
3630
  provisionAgent,
3437
3631
  provisionInfo,
3438
3632
  publisherBindingBytes,
3439
3633
  publisherBindingMessage,
3634
+ recoveryMessage,
3635
+ recoveryPublicKey,
3636
+ recoverySeedText,
3440
3637
  releaseAgent,
3441
3638
  releaseConsent,
3442
3639
  releaseNotesUrl,
3640
+ signRecovery,
3443
3641
  stalledBy,
3444
3642
  transportKeyBinding,
3445
3643
  trustSignedRelease,