@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.cjs CHANGED
@@ -50,6 +50,7 @@ __export(index_exports, {
50
50
  IdempotencyMismatch: () => IdempotencyMismatch,
51
51
  KMS_RELEASE_SIGNER: () => KMS_RELEASE_SIGNER,
52
52
  LoginRequired: () => LoginRequired,
53
+ MAX_RECOVERY_NONCE_BYTES: () => MAX_RECOVERY_NONCE_BYTES,
53
54
  MAX_REPORT_PAGE: () => MAX_REPORT_PAGE,
54
55
  MOCK_QUOTE_HEADER: () => MOCK_QUOTE_HEADER,
55
56
  NODE_RELEASE_SIGNER: () => NODE_RELEASE_SIGNER,
@@ -59,7 +60,9 @@ __export(index_exports, {
59
60
  OriginTaken: () => OriginTaken,
60
61
  PRODUCTION_PROFILE: () => PRODUCTION_PROFILE,
61
62
  QuotaExceeded: () => QuotaExceeded,
63
+ RECOVERY_DOMAIN: () => RECOVERY_DOMAIN,
62
64
  RELEASE_VERIFIER: () => RELEASE_VERIFIER,
65
+ RecoveryNotReady: () => RecoveryNotReady,
63
66
  ReleaseDeadlineError: () => ReleaseDeadlineError,
64
67
  SEALED_CONTENT_TYPE: () => SEALED_CONTENT_TYPE,
65
68
  SEALED_PATH: () => SEALED_PATH,
@@ -69,6 +72,7 @@ __export(index_exports, {
69
72
  TrapError: () => TrapError,
70
73
  UnavailableError: () => UnavailableError,
71
74
  WrongNode: () => WrongNode,
75
+ WrongRecoveryCode: () => WrongRecoveryCode,
72
76
  agentKeyBinding: () => agentKeyBinding,
73
77
  agentQuoteVerifier: () => agentQuoteVerifier,
74
78
  attestAgent: () => attestAgent,
@@ -92,18 +96,24 @@ __export(index_exports, {
92
96
  fromB64url: () => fromB64url,
93
97
  frontendManifest: () => frontendManifest,
94
98
  generateOwnerKey: () => generateOwnerKey,
99
+ newRecoverySeed: () => newRecoverySeed,
95
100
  orgCapabilities: () => orgCapabilities,
96
101
  ownerPublicKey: () => ownerPublicKey,
97
102
  pageDeployment: () => pageDeployment,
98
103
  pageQuery: () => pageQuery,
104
+ parseRecoverySeed: () => parseRecoverySeed,
99
105
  pendingApprovalsFrom: () => pendingApprovalsFrom,
100
106
  provisionAgent: () => provisionAgent,
101
107
  provisionInfo: () => provisionInfo,
102
108
  publisherBindingBytes: () => publisherBindingBytes,
103
109
  publisherBindingMessage: () => publisherBindingMessage,
110
+ recoveryMessage: () => recoveryMessage,
111
+ recoveryPublicKey: () => recoveryPublicKey,
112
+ recoverySeedText: () => recoverySeedText,
104
113
  releaseAgent: () => releaseAgent,
105
114
  releaseConsent: () => releaseConsent,
106
115
  releaseNotesUrl: () => releaseNotesUrl,
116
+ signRecovery: () => signRecovery,
107
117
  stalledBy: () => stalledBy,
108
118
  transportKeyBinding: () => transportKeyBinding,
109
119
  trustSignedRelease: () => trustSignedRelease,
@@ -254,20 +264,20 @@ async function x25519KeyPair(secret) {
254
264
  const publicKey = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey));
255
265
  return { privateKey: pair.privateKey, publicKey };
256
266
  }
257
- const privateKey = await crypto.subtle.importKey(
267
+ const privateKey2 = await crypto.subtle.importKey(
258
268
  "pkcs8",
259
269
  concat(PKCS8_X25519_PREFIX, secret),
260
270
  { name: "X25519" },
261
271
  true,
262
272
  ["deriveBits"]
263
273
  );
264
- const jwk = await crypto.subtle.exportKey("jwk", privateKey);
265
- return { privateKey, publicKey: base64UrlDecode(jwk.x) };
274
+ const jwk = await crypto.subtle.exportKey("jwk", privateKey2);
275
+ return { privateKey: privateKey2, publicKey: base64UrlDecode(jwk.x) };
266
276
  }
267
- async function dh(privateKey, publicKey) {
277
+ async function dh(privateKey2, publicKey) {
268
278
  const peer = await crypto.subtle.importKey("raw", publicKey, { name: "X25519" }, false, []);
269
279
  return new Uint8Array(
270
- await crypto.subtle.deriveBits({ name: "X25519", public: peer }, privateKey, 256)
280
+ await crypto.subtle.deriveBits({ name: "X25519", public: peer }, privateKey2, 256)
271
281
  );
272
282
  }
273
283
  function noiseNonce(n) {
@@ -1537,6 +1547,19 @@ var OriginTaken = class extends CalimeroError {
1537
1547
  this.name = "OriginTaken";
1538
1548
  }
1539
1549
  };
1550
+ var RecoveryNotReady = class extends CalimeroError {
1551
+ constructor(message, details = {}) {
1552
+ super(409, "RECOVERY_NOT_READY", message, details);
1553
+ this.name = "RecoveryNotReady";
1554
+ this.readyAt = typeof details.readyAt === "number" ? details.readyAt : void 0;
1555
+ }
1556
+ };
1557
+ var WrongRecoveryCode = class extends CalimeroError {
1558
+ constructor(message, details = {}) {
1559
+ super(401, "UNAUTHENTICATED", message || "unknown or used recovery code", details);
1560
+ this.name = "WrongRecoveryCode";
1561
+ }
1562
+ };
1540
1563
  var SignUpRefused = class extends CalimeroError {
1541
1564
  constructor(message, details = {}) {
1542
1565
  super(403, "SIGN_UP_REFUSED", message, details);
@@ -1626,6 +1649,8 @@ function errorFrom(status, body, retryAfterHeader) {
1626
1649
  return new OriginTaken(message, details);
1627
1650
  case "429 QUOTA_EXCEEDED":
1628
1651
  return new QuotaExceeded(message, details);
1652
+ case "409 RECOVERY_NOT_READY":
1653
+ return new RecoveryNotReady(message, details);
1629
1654
  case "403 SIGN_UP_REFUSED":
1630
1655
  return new SignUpRefused(message, details);
1631
1656
  case "412 CONFIRM_REQUIRED":
@@ -1704,6 +1729,72 @@ function browserPasskey() {
1704
1729
  };
1705
1730
  }
1706
1731
 
1732
+ // src/cloud-v2/recovery.ts
1733
+ var RECOVERY_DOMAIN = "calimero.org-recovery.v1\0";
1734
+ var MAX_RECOVERY_NONCE_BYTES = 64;
1735
+ var PKCS8_ED25519 = Uint8Array.from([
1736
+ 48,
1737
+ 46,
1738
+ 2,
1739
+ 1,
1740
+ 0,
1741
+ 48,
1742
+ 5,
1743
+ 6,
1744
+ 3,
1745
+ 43,
1746
+ 101,
1747
+ 112,
1748
+ 4,
1749
+ 34,
1750
+ 4,
1751
+ 32
1752
+ ]);
1753
+ function newRecoverySeed() {
1754
+ return crypto.getRandomValues(new Uint8Array(32));
1755
+ }
1756
+ function recoverySeedText(seed) {
1757
+ checkSeed(seed);
1758
+ const hex2 = Array.from(seed, (b) => b.toString(16).padStart(2, "0")).join("");
1759
+ const groups = [];
1760
+ for (let i = 0; i < hex2.length; i += 8) groups.push(hex2.slice(i, i + 8));
1761
+ return groups.join("-");
1762
+ }
1763
+ function parseRecoverySeed(text) {
1764
+ const digits = text.replace(/[-\s]/g, "");
1765
+ if (!/^[0-9a-fA-F]{64}$/.test(digits)) throw new Error("a recovery seed is 64 hex digits");
1766
+ return Uint8Array.from({ length: 32 }, (_, i) => parseInt(digits.slice(2 * i, 2 * i + 2), 16));
1767
+ }
1768
+ function recoveryMessage(org, account, nonce2) {
1769
+ return new TextEncoder().encode(`${RECOVERY_DOMAIN}${org}\0${account}\0${nonce2}`);
1770
+ }
1771
+ async function recoveryPublicKey(seed) {
1772
+ const key = await privateKey(seed, true);
1773
+ const jwk = await crypto.subtle.exportKey("jwk", key);
1774
+ if (!jwk.x) throw new Error("this runtime cannot export an Ed25519 public key");
1775
+ return jwk.x;
1776
+ }
1777
+ async function signRecovery(seed, org, account, nonce2) {
1778
+ const nonceBytes = new TextEncoder().encode(nonce2).length;
1779
+ if (nonceBytes === 0 || nonceBytes > MAX_RECOVERY_NONCE_BYTES) {
1780
+ throw new Error(`a recovery nonce is 1 to ${MAX_RECOVERY_NONCE_BYTES} bytes`);
1781
+ }
1782
+ const key = await privateKey(seed, false);
1783
+ const sig = await crypto.subtle.sign("Ed25519", key, buf2(recoveryMessage(org, account, nonce2)));
1784
+ return b64url(new Uint8Array(sig));
1785
+ }
1786
+ function checkSeed(seed) {
1787
+ if (seed.length !== 32) throw new Error("a recovery seed is 32 bytes");
1788
+ }
1789
+ function privateKey(seed, extractable) {
1790
+ checkSeed(seed);
1791
+ const pkcs8 = new Uint8Array(PKCS8_ED25519.length + 32);
1792
+ pkcs8.set(PKCS8_ED25519);
1793
+ pkcs8.set(seed, PKCS8_ED25519.length);
1794
+ return crypto.subtle.importKey("pkcs8", buf2(pkcs8), { name: "Ed25519" }, extractable, ["sign"]);
1795
+ }
1796
+ var buf2 = (b) => b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
1797
+
1707
1798
  // src/cloud-v2/approvals.ts
1708
1799
  var MAX_REPORT_PAGE = 100;
1709
1800
  function pageQuery(page = {}) {
@@ -1906,8 +1997,9 @@ function retryDelayMs(retry, options) {
1906
1997
  return Math.min(options.baseDelayMs * 2 ** retry, options.maxDelayMs);
1907
1998
  }
1908
1999
  function ceremony(path) {
1909
- return path === "auth/refresh" || path.startsWith("auth/passkey/");
2000
+ return path === "auth/refresh" || path.startsWith("auth/passkey/") || RECOVERY_CEREMONIES.has(path);
1910
2001
  }
2002
+ var RECOVERY_CEREMONIES = /* @__PURE__ */ new Set(["auth/recovery/begin", "auth/recovery/start", "auth/recovery/finish"]);
1911
2003
  var CalimeroClient = class _CalimeroClient {
1912
2004
  constructor(options) {
1913
2005
  /** The one in use: an index into {@link nodeUrls}. */
@@ -2342,6 +2434,71 @@ var CalimeroClient = class _CalimeroClient {
2342
2434
  async labelPasskey(credentialId, label) {
2343
2435
  await this.post(`auth/passkeys/${seg(credentialId)}`, { label });
2344
2436
  }
2437
+ // ---------------------------------------------------------------- account recovery
2438
+ //
2439
+ // core #4844 (`docs/specs/account-recovery.md`): a recovery code, made with
2440
+ // a passkey and shown once, lets someone who lost every passkey register a
2441
+ // new one; it is added, and the account signed in, 72 hours later unless a
2442
+ // passkey the account still has cancels it first.
2443
+ /**
2444
+ * Make the account's recovery code (replacing any it had), approved with
2445
+ * `passkey` (`412 CONFIRM_REQUIRED`). Answers the code, once: show it to
2446
+ * the person to keep offline; the node keeps only its public key. A passkey
2447
+ * session on the cluster's own origin only (never an API key or a
2448
+ * package-bound session).
2449
+ */
2450
+ async createRecoveryCode(passkey) {
2451
+ const { code } = await this.postConfirmed("auth/recovery", {}, passkey);
2452
+ return code;
2453
+ }
2454
+ /** Remove the account's recovery code, approved with `passkey`. */
2455
+ async removeRecoveryCode(passkey) {
2456
+ await this.deleteConfirmed("auth/recovery", passkey);
2457
+ }
2458
+ /**
2459
+ * Signed out: start recovering the account `code` names. `passkey` makes a
2460
+ * new passkey for it (the browser's create prompt, user verified); the
2461
+ * node spends the code and keeps the passkey as pending. Nothing is added
2462
+ * and no session opens: finish with {@link finishAccountRecovery} at
2463
+ * `readyAt`. A code the cluster does not hold (mistyped, replaced or
2464
+ * spent) is {@link WrongRecoveryCode}; an account with a recovery already
2465
+ * under way `409 RECOVERY_PENDING`.
2466
+ */
2467
+ async startAccountRecovery(code, passkey) {
2468
+ try {
2469
+ const begin = await this.post("auth/recovery/begin", { code });
2470
+ const registration = await passkey.create(begin.options);
2471
+ return await this.post("auth/recovery/start", { challengeId: begin.challengeId, code, registration });
2472
+ } catch (error) {
2473
+ if (error instanceof LoginRequired) throw new WrongRecoveryCode(error.message.replace(/^401 UNAUTHENTICATED: /, ""), error.details);
2474
+ throw error;
2475
+ }
2476
+ }
2477
+ /**
2478
+ * Cancel the recovery of the signed-in account under way, approved with
2479
+ * `passkey` (one the account already has). The code stays spent: make a
2480
+ * new one.
2481
+ */
2482
+ async cancelAccountRecovery(passkey) {
2483
+ await this.postConfirmed("auth/recovery/cancel", {}, passkey);
2484
+ }
2485
+ /**
2486
+ * Signed out, once the delay passed: finish the recovery with the passkey
2487
+ * it registered (`passkey` signs the node's sign-in challenge). The passkey
2488
+ * joins the account, every other session of the account ends, and this
2489
+ * client is signed in. `accountId` (from the start) lets the node refuse
2490
+ * early and offer only that passkey; before `readyAt` it is
2491
+ * {@link RecoveryNotReady}, with no recovery under way {@link NotFound}.
2492
+ */
2493
+ async finishAccountRecovery(passkey, accountId) {
2494
+ const session = await this.postConfirmed(
2495
+ "auth/recovery/finish",
2496
+ accountId ? { accountId } : {},
2497
+ passkey
2498
+ );
2499
+ this.useSession(session);
2500
+ return session;
2501
+ }
2345
2502
  /** What this account uses (apps, bundle bytes, API keys, owned orgs), with the cluster's quotas. */
2346
2503
  usage() {
2347
2504
  return this.get("auth/usage");
@@ -2655,10 +2812,51 @@ var CalimeroClient = class _CalimeroClient {
2655
2812
  await this.delete(`auth/aliases/${seg(alias)}`);
2656
2813
  }
2657
2814
  // ---------------------------------------------------------------- organizations
2658
- async createOrg(name) {
2659
- const { orgId } = await this.post("orgs", { name });
2815
+ /**
2816
+ * Create an organization. `recoveryKey` (from {@link recoveryPublicKey}) lets
2817
+ * whoever holds its seed recover the organization if its owner loses every
2818
+ * passkey; keep the seed offline ({@link recoverySeedText}).
2819
+ */
2820
+ async createOrg(name, options = {}) {
2821
+ const body = { name };
2822
+ if (options.recoveryKey !== void 0) body.recoveryKey = options.recoveryKey;
2823
+ const { orgId } = await this.post("orgs", body);
2660
2824
  return orgId;
2661
2825
  }
2826
+ /**
2827
+ * Set or rotate (`publicKey`, from {@link recoveryPublicKey}) or remove
2828
+ * (`null`) the organization's recovery key: its owner only, approved with
2829
+ * `passkey`. A recovery under way is cancelled, and the old seed's
2830
+ * signatures are refused from then on.
2831
+ */
2832
+ async setRecoveryKey(org, publicKey, passkey) {
2833
+ await this.postConfirmed(`orgs/${seg(org)}/recovery`, { publicKey }, passkey);
2834
+ }
2835
+ /**
2836
+ * Start handing `org` to the signed-in account with the recovery seed,
2837
+ * which signs here and never leaves this process. Finish with
2838
+ * {@link finishRecovery} once `readyAt` has passed; until then the owner may
2839
+ * cancel. A wrong seed (or no organization, or none set) is a `403`.
2840
+ */
2841
+ async startRecovery(org, seed) {
2842
+ const account = (await this.me()).accountId;
2843
+ const nonce2 = b64url(crypto.getRandomValues(new Uint8Array(16)));
2844
+ const signature = await signRecovery(seed, org, account, nonce2);
2845
+ return this.post(`orgs/${seg(org)}/recovery/start`, { nonce: nonce2, signature });
2846
+ }
2847
+ /** Cancel the recovery under way: the organization's owner, approved with `passkey`. */
2848
+ async cancelRecovery(org, passkey) {
2849
+ await this.postConfirmed(`orgs/${seg(org)}/recovery/cancel`, {}, passkey);
2850
+ }
2851
+ /**
2852
+ * Finish a recovery this account started, once its delay passed
2853
+ * (`409 RECOVERY_NOT_READY` before, with `readyAt`), approved with this
2854
+ * account's `passkey`. The account becomes the owner; the previous owner
2855
+ * leaves the organization.
2856
+ */
2857
+ finishRecovery(org, passkey) {
2858
+ return this.postConfirmed(`orgs/${seg(org)}/recovery/finish`, {}, passkey);
2859
+ }
2662
2860
  /** The organization, with the teams this caller reaches or manages. */
2663
2861
  getOrg(org) {
2664
2862
  return this.get(`orgs/${seg(org)}`);