@calimero-network/mero-js 26.2.0 → 26.2.2

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
@@ -2535,6 +2535,9 @@ var STATE_PARAM = "link_state";
2535
2535
  var LIFETIME_MS = 10 * 60 * 1e3;
2536
2536
  var ACCOUNT_HEX = /^[0-9a-f]{64}$/;
2537
2537
  var MAX_PENDING = 20;
2538
+ var VERIFIER_BYTES = 32;
2539
+ var VERIFIER_HEX = /^[0-9a-f]{64}$/;
2540
+ var LINK_GRANT_USE = "account-link-grant";
2538
2541
  function readPending(key, now) {
2539
2542
  try {
2540
2543
  const raw = localStorage.getItem(key);
@@ -2557,10 +2560,7 @@ function writePending(key, pending) {
2557
2560
  function beginState(key, payload) {
2558
2561
  const now = Date.now();
2559
2562
  const pending = readPending(key, now);
2560
- const bytes = crypto.getRandomValues(new Uint8Array(16));
2561
- const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
2562
- ""
2563
- );
2563
+ const state = hex(crypto.getRandomValues(new Uint8Array(16)));
2564
2564
  pending[state] = { ...payload, expiresAt: now + LIFETIME_MS };
2565
2565
  writePending(key, Object.fromEntries(Object.entries(pending).slice(-MAX_PENDING)));
2566
2566
  return state;
@@ -2574,16 +2574,41 @@ function takeState(key, state) {
2574
2574
  writePending(key, pending);
2575
2575
  return entry;
2576
2576
  }
2577
- function beginLinkState(account, returnTo) {
2577
+ async function linkChallenge(verifier) {
2578
+ const bytes = Uint8Array.from(verifier.match(/../g) ?? [], (b) => parseInt(b, 16));
2579
+ return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
2580
+ }
2581
+ async function beginLinkState(account, returnTo) {
2578
2582
  if (!ACCOUNT_HEX.test(account)) {
2579
2583
  throw new Error("accountId must be 64 lowercase hex characters");
2580
2584
  }
2581
2585
  const url2 = new URL(returnTo);
2582
- url2.searchParams.set(STATE_PARAM, beginState(KEY, { account }));
2583
- return url2.toString();
2586
+ const verifier = hex(crypto.getRandomValues(new Uint8Array(VERIFIER_BYTES)));
2587
+ const challenge = await linkChallenge(verifier);
2588
+ url2.searchParams.set(STATE_PARAM, beginState(KEY, { account, verifier }));
2589
+ return { callbackUrl: url2.toString(), challenge };
2590
+ }
2591
+ function takeLinkVerifier(search, account) {
2592
+ const pending = takeState(
2593
+ KEY,
2594
+ new URLSearchParams(search).get(STATE_PARAM)
2595
+ );
2596
+ if (!pending || pending.account !== account) return null;
2597
+ return typeof pending.verifier === "string" && VERIFIER_HEX.test(pending.verifier) ? pending.verifier : null;
2584
2598
  }
2585
- function takeLinkState(search) {
2586
- return takeState(KEY, new URLSearchParams(search).get(STATE_PARAM));
2599
+ function linkGrantClaims(grant) {
2600
+ try {
2601
+ const body = grant.split(".")[0].replace(/-/g, "+").replace(/_/g, "/");
2602
+ const claims = JSON.parse(
2603
+ new TextDecoder().decode(Uint8Array.from(atob(body), (c) => c.charCodeAt(0)))
2604
+ );
2605
+ const { use, sub, acc, chal } = claims;
2606
+ if (use !== LINK_GRANT_USE) return null;
2607
+ if (typeof sub !== "string" || typeof acc !== "string" || typeof chal !== "string") return null;
2608
+ return { login: sub, account: acc, challenge: chal };
2609
+ } catch {
2610
+ return null;
2611
+ }
2587
2612
  }
2588
2613
  function withoutLinkState(search) {
2589
2614
  const params = new URLSearchParams(search);
@@ -9801,21 +9826,23 @@ function base643(bytes) {
9801
9826
 
9802
9827
  // src/cloud/link-redirect.ts
9803
9828
  var DEFAULT_CLOUD_WEB_URL = "https://cloud.calimero.network";
9804
- function cloudLinkUrl(options) {
9829
+ async function cloudLinkUrl(options) {
9805
9830
  const url2 = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
9831
+ const { callbackUrl, challenge } = await beginLinkState(options.accountId, options.returnTo);
9806
9832
  url2.searchParams.set("link-account", options.accountId);
9807
- url2.searchParams.set("callback-url", beginLinkState(options.accountId, options.returnTo));
9833
+ url2.searchParams.set("challenge", challenge);
9834
+ url2.searchParams.set("callback-url", callbackUrl);
9808
9835
  return url2.toString();
9809
9836
  }
9810
- function beginCloudLink(options) {
9811
- window.location.assign(cloudLinkUrl(options));
9837
+ async function beginCloudLink(options) {
9838
+ window.location.assign(await cloudLinkUrl(options));
9812
9839
  throw new Error("redirecting to the cloud to authorise this link");
9813
9840
  }
9814
9841
  function readCloudLinkCallback(location2 = window.location) {
9815
9842
  const params = new URLSearchParams(location2.hash.replace(/^#/, ""));
9816
9843
  const error = params.get("error");
9817
9844
  if (error) {
9818
- takeLinkState(location2.search ?? "");
9845
+ takeLinkVerifier(location2.search ?? "", null);
9819
9846
  stripFragment();
9820
9847
  throw new Error(
9821
9848
  error === "denied" ? "the account link was declined at the cloud" : `the cloud refused this link: ${error}`
@@ -9824,14 +9851,16 @@ function readCloudLinkCallback(location2 = window.location) {
9824
9851
  const grant = params.get("grant");
9825
9852
  const account = params.get("account");
9826
9853
  if (!grant || !account) return null;
9827
- if (takeLinkState(location2.search ?? "")?.account !== account) {
9854
+ const verifier = takeLinkVerifier(location2.search ?? "", account);
9855
+ const claims = linkGrantClaims(grant);
9856
+ if (!verifier || claims?.account !== account) {
9828
9857
  stripFragment();
9829
9858
  throw new Error(
9830
9859
  "this account link answers a request this browser did not make - start the link again from here"
9831
9860
  );
9832
9861
  }
9833
9862
  stripFragment();
9834
- return { grant, account };
9863
+ return { grant, account, login: claims.login, verifier };
9835
9864
  }
9836
9865
  function stripFragment() {
9837
9866
  if (typeof history === "undefined" || !history.replaceState) return;
@@ -9848,7 +9877,11 @@ async function completeCloudLink(options) {
9848
9877
  cloudBaseUrl: options.cloudBaseUrl,
9849
9878
  fetch: options.fetch
9850
9879
  });
9851
- return cloud.linkAccountWithGrant({ grant: options.grant, signer: root.signer });
9880
+ return cloud.linkAccountWithGrant({
9881
+ grant: options.grant,
9882
+ verifier: options.verifier,
9883
+ signer: root.signer
9884
+ });
9852
9885
  }
9853
9886
 
9854
9887
  // src/cloud/enrol-redirect.ts
@@ -10142,7 +10175,7 @@ function bytes32(value, label2) {
10142
10175
  }
10143
10176
 
10144
10177
  // src/namespace-op/namespace-op.ts
10145
- var SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 25;
10178
+ var SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 26;
10146
10179
  var NAMESPACE_SIGN_DOMAIN = new TextEncoder().encode("calimero.namespace.v1");
10147
10180
  var NAMESPACE_OP_ROOT = 0;
10148
10181
  var ROOT_OP2 = {
@@ -11692,11 +11725,13 @@ var CloudClient = class {
11692
11725
  * Static, so it needs no client and no session: there is nothing to
11693
11726
  * authenticate as yet.
11694
11727
  */
11695
- static accountLinkHandoff(options) {
11728
+ static async accountLinkHandoff(options) {
11696
11729
  const portal = options.portalUrl.replace(/\/+$/, "");
11730
+ const { callbackUrl, challenge } = await beginLinkState(options.accountId, options.callbackUrl);
11697
11731
  const query = new URLSearchParams({
11698
11732
  "link-account": options.accountId,
11699
- "callback-url": beginLinkState(options.accountId, options.callbackUrl)
11733
+ challenge,
11734
+ "callback-url": callbackUrl
11700
11735
  });
11701
11736
  return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
11702
11737
  }
@@ -11727,11 +11762,15 @@ var CloudClient = class {
11727
11762
  const accountId = params.get("account");
11728
11763
  const error = params.get("error");
11729
11764
  const out = {};
11730
- if (error) takeLinkState(search);
11731
- if (grant && takeLinkState(search)?.account !== accountId) {
11732
- return { error: "unexpected_callback" };
11765
+ if (error) takeLinkVerifier(search, null);
11766
+ if (grant) {
11767
+ const verifier = takeLinkVerifier(search, accountId);
11768
+ const claims = linkGrantClaims(grant);
11769
+ if (!verifier || claims?.account !== accountId) return { error: "unexpected_callback" };
11770
+ out.grant = grant;
11771
+ out.login = claims.login;
11772
+ out.verifier = verifier;
11733
11773
  }
11734
- if (grant) out.grant = grant;
11735
11774
  if (accountId) out.accountId = accountId;
11736
11775
  if (error) out.error = error;
11737
11776
  return out;
@@ -11746,24 +11785,40 @@ var CloudClient = class {
11746
11785
  *
11747
11786
  * The signature is an ordinary account-link signature with the grant as the
11748
11787
  * nonce, so the same {@link signAccountLink} every other link path uses signs
11749
- * this one. Both halves are required: a grant without the key links nothing,
11750
- * which is what makes it safe to carry through a browser redirect.
11788
+ * this one. The grant is bound to the verifier of the browser that asked for
11789
+ * it, and is refused here, before the root signs, unless it carries that
11790
+ * verifier's challenge and names this root's account.
11751
11791
  *
11752
11792
  * A `403` means the grant was bad, expired, already spent, or signed by a key
11753
11793
  * that names a different account; `409` that the account belongs to another
11754
11794
  * login; `402` that the plan is full.
11755
11795
  */
11756
11796
  async linkAccountWithGrant(options) {
11797
+ if (typeof options.verifier !== "string" || !VERIFIER_HEX.test(options.verifier)) {
11798
+ throw new Error("no verifier for this grant - start the link again from this browser");
11799
+ }
11800
+ const claims = linkGrantClaims(options.grant);
11801
+ if (claims?.challenge !== await linkChallenge(options.verifier)) {
11802
+ throw new Error(
11803
+ "this grant was not issued to this link request - start the link again from this browser"
11804
+ );
11805
+ }
11757
11806
  const { signer, publicKey, accountId } = await resolveRootPair(
11758
11807
  options.rootSecret,
11759
11808
  options.signer
11760
11809
  );
11810
+ if (claims.account !== accountId) {
11811
+ throw new Error(
11812
+ `this grant is for account ${claims.account}, but the root here derives ${accountId} - it was minted for a different account`
11813
+ );
11814
+ }
11761
11815
  const body = await this.request(
11762
11816
  "POST",
11763
11817
  "/api/cloud/accounts/link",
11764
11818
  {
11765
11819
  body: {
11766
11820
  grant: options.grant,
11821
+ verifier: options.verifier,
11767
11822
  root_public_key: publicKey,
11768
11823
  signature: await signAccountLink({
11769
11824
  signer,