@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.cjs CHANGED
@@ -2830,6 +2830,9 @@ var STATE_PARAM = "link_state";
2830
2830
  var LIFETIME_MS = 10 * 60 * 1e3;
2831
2831
  var ACCOUNT_HEX = /^[0-9a-f]{64}$/;
2832
2832
  var MAX_PENDING = 20;
2833
+ var VERIFIER_BYTES = 32;
2834
+ var VERIFIER_HEX = /^[0-9a-f]{64}$/;
2835
+ var LINK_GRANT_USE = "account-link-grant";
2833
2836
  function readPending(key, now) {
2834
2837
  try {
2835
2838
  const raw = localStorage.getItem(key);
@@ -2852,10 +2855,7 @@ function writePending(key, pending) {
2852
2855
  function beginState(key, payload) {
2853
2856
  const now = Date.now();
2854
2857
  const pending = readPending(key, now);
2855
- const bytes = crypto.getRandomValues(new Uint8Array(16));
2856
- const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
2857
- ""
2858
- );
2858
+ const state = hex(crypto.getRandomValues(new Uint8Array(16)));
2859
2859
  pending[state] = { ...payload, expiresAt: now + LIFETIME_MS };
2860
2860
  writePending(key, Object.fromEntries(Object.entries(pending).slice(-MAX_PENDING)));
2861
2861
  return state;
@@ -2869,16 +2869,41 @@ function takeState(key, state) {
2869
2869
  writePending(key, pending);
2870
2870
  return entry;
2871
2871
  }
2872
- function beginLinkState(account, returnTo) {
2872
+ async function linkChallenge(verifier) {
2873
+ const bytes = Uint8Array.from(verifier.match(/../g) ?? [], (b) => parseInt(b, 16));
2874
+ return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
2875
+ }
2876
+ async function beginLinkState(account, returnTo) {
2873
2877
  if (!ACCOUNT_HEX.test(account)) {
2874
2878
  throw new Error("accountId must be 64 lowercase hex characters");
2875
2879
  }
2876
2880
  const url2 = new URL(returnTo);
2877
- url2.searchParams.set(STATE_PARAM, beginState(KEY, { account }));
2878
- return url2.toString();
2881
+ const verifier = hex(crypto.getRandomValues(new Uint8Array(VERIFIER_BYTES)));
2882
+ const challenge = await linkChallenge(verifier);
2883
+ url2.searchParams.set(STATE_PARAM, beginState(KEY, { account, verifier }));
2884
+ return { callbackUrl: url2.toString(), challenge };
2885
+ }
2886
+ function takeLinkVerifier(search, account) {
2887
+ const pending = takeState(
2888
+ KEY,
2889
+ new URLSearchParams(search).get(STATE_PARAM)
2890
+ );
2891
+ if (!pending || pending.account !== account) return null;
2892
+ return typeof pending.verifier === "string" && VERIFIER_HEX.test(pending.verifier) ? pending.verifier : null;
2879
2893
  }
2880
- function takeLinkState(search) {
2881
- return takeState(KEY, new URLSearchParams(search).get(STATE_PARAM));
2894
+ function linkGrantClaims(grant) {
2895
+ try {
2896
+ const body = grant.split(".")[0].replace(/-/g, "+").replace(/_/g, "/");
2897
+ const claims = JSON.parse(
2898
+ new TextDecoder().decode(Uint8Array.from(atob(body), (c) => c.charCodeAt(0)))
2899
+ );
2900
+ const { use, sub, acc, chal } = claims;
2901
+ if (use !== LINK_GRANT_USE) return null;
2902
+ if (typeof sub !== "string" || typeof acc !== "string" || typeof chal !== "string") return null;
2903
+ return { login: sub, account: acc, challenge: chal };
2904
+ } catch {
2905
+ return null;
2906
+ }
2882
2907
  }
2883
2908
  function withoutLinkState(search) {
2884
2909
  const params = new URLSearchParams(search);
@@ -10096,21 +10121,23 @@ function base643(bytes) {
10096
10121
 
10097
10122
  // src/cloud/link-redirect.ts
10098
10123
  var DEFAULT_CLOUD_WEB_URL = "https://cloud.calimero.network";
10099
- function cloudLinkUrl(options) {
10124
+ async function cloudLinkUrl(options) {
10100
10125
  const url2 = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
10126
+ const { callbackUrl, challenge } = await beginLinkState(options.accountId, options.returnTo);
10101
10127
  url2.searchParams.set("link-account", options.accountId);
10102
- url2.searchParams.set("callback-url", beginLinkState(options.accountId, options.returnTo));
10128
+ url2.searchParams.set("challenge", challenge);
10129
+ url2.searchParams.set("callback-url", callbackUrl);
10103
10130
  return url2.toString();
10104
10131
  }
10105
- function beginCloudLink(options) {
10106
- window.location.assign(cloudLinkUrl(options));
10132
+ async function beginCloudLink(options) {
10133
+ window.location.assign(await cloudLinkUrl(options));
10107
10134
  throw new Error("redirecting to the cloud to authorise this link");
10108
10135
  }
10109
10136
  function readCloudLinkCallback(location2 = window.location) {
10110
10137
  const params = new URLSearchParams(location2.hash.replace(/^#/, ""));
10111
10138
  const error = params.get("error");
10112
10139
  if (error) {
10113
- takeLinkState(location2.search ?? "");
10140
+ takeLinkVerifier(location2.search ?? "", null);
10114
10141
  stripFragment();
10115
10142
  throw new Error(
10116
10143
  error === "denied" ? "the account link was declined at the cloud" : `the cloud refused this link: ${error}`
@@ -10119,14 +10146,16 @@ function readCloudLinkCallback(location2 = window.location) {
10119
10146
  const grant = params.get("grant");
10120
10147
  const account = params.get("account");
10121
10148
  if (!grant || !account) return null;
10122
- if (takeLinkState(location2.search ?? "")?.account !== account) {
10149
+ const verifier = takeLinkVerifier(location2.search ?? "", account);
10150
+ const claims = linkGrantClaims(grant);
10151
+ if (!verifier || claims?.account !== account) {
10123
10152
  stripFragment();
10124
10153
  throw new Error(
10125
10154
  "this account link answers a request this browser did not make - start the link again from here"
10126
10155
  );
10127
10156
  }
10128
10157
  stripFragment();
10129
- return { grant, account };
10158
+ return { grant, account, login: claims.login, verifier };
10130
10159
  }
10131
10160
  function stripFragment() {
10132
10161
  if (typeof history === "undefined" || !history.replaceState) return;
@@ -10143,7 +10172,11 @@ async function completeCloudLink(options) {
10143
10172
  cloudBaseUrl: options.cloudBaseUrl,
10144
10173
  fetch: options.fetch
10145
10174
  });
10146
- return cloud.linkAccountWithGrant({ grant: options.grant, signer: root.signer });
10175
+ return cloud.linkAccountWithGrant({
10176
+ grant: options.grant,
10177
+ verifier: options.verifier,
10178
+ signer: root.signer
10179
+ });
10147
10180
  }
10148
10181
 
10149
10182
  // src/cloud/enrol-redirect.ts
@@ -10437,7 +10470,7 @@ function bytes32(value, label2) {
10437
10470
  }
10438
10471
 
10439
10472
  // src/namespace-op/namespace-op.ts
10440
- var SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 25;
10473
+ var SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 26;
10441
10474
  var NAMESPACE_SIGN_DOMAIN = new TextEncoder().encode("calimero.namespace.v1");
10442
10475
  var NAMESPACE_OP_ROOT = 0;
10443
10476
  var ROOT_OP2 = {
@@ -11987,11 +12020,13 @@ var CloudClient = class {
11987
12020
  * Static, so it needs no client and no session: there is nothing to
11988
12021
  * authenticate as yet.
11989
12022
  */
11990
- static accountLinkHandoff(options) {
12023
+ static async accountLinkHandoff(options) {
11991
12024
  const portal = options.portalUrl.replace(/\/+$/, "");
12025
+ const { callbackUrl, challenge } = await beginLinkState(options.accountId, options.callbackUrl);
11992
12026
  const query = new URLSearchParams({
11993
12027
  "link-account": options.accountId,
11994
- "callback-url": beginLinkState(options.accountId, options.callbackUrl)
12028
+ challenge,
12029
+ "callback-url": callbackUrl
11995
12030
  });
11996
12031
  return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
11997
12032
  }
@@ -12022,11 +12057,15 @@ var CloudClient = class {
12022
12057
  const accountId = params.get("account");
12023
12058
  const error = params.get("error");
12024
12059
  const out = {};
12025
- if (error) takeLinkState(search);
12026
- if (grant && takeLinkState(search)?.account !== accountId) {
12027
- return { error: "unexpected_callback" };
12060
+ if (error) takeLinkVerifier(search, null);
12061
+ if (grant) {
12062
+ const verifier = takeLinkVerifier(search, accountId);
12063
+ const claims = linkGrantClaims(grant);
12064
+ if (!verifier || claims?.account !== accountId) return { error: "unexpected_callback" };
12065
+ out.grant = grant;
12066
+ out.login = claims.login;
12067
+ out.verifier = verifier;
12028
12068
  }
12029
- if (grant) out.grant = grant;
12030
12069
  if (accountId) out.accountId = accountId;
12031
12070
  if (error) out.error = error;
12032
12071
  return out;
@@ -12041,24 +12080,40 @@ var CloudClient = class {
12041
12080
  *
12042
12081
  * The signature is an ordinary account-link signature with the grant as the
12043
12082
  * nonce, so the same {@link signAccountLink} every other link path uses signs
12044
- * this one. Both halves are required: a grant without the key links nothing,
12045
- * which is what makes it safe to carry through a browser redirect.
12083
+ * this one. The grant is bound to the verifier of the browser that asked for
12084
+ * it, and is refused here, before the root signs, unless it carries that
12085
+ * verifier's challenge and names this root's account.
12046
12086
  *
12047
12087
  * A `403` means the grant was bad, expired, already spent, or signed by a key
12048
12088
  * that names a different account; `409` that the account belongs to another
12049
12089
  * login; `402` that the plan is full.
12050
12090
  */
12051
12091
  async linkAccountWithGrant(options) {
12092
+ if (typeof options.verifier !== "string" || !VERIFIER_HEX.test(options.verifier)) {
12093
+ throw new Error("no verifier for this grant - start the link again from this browser");
12094
+ }
12095
+ const claims = linkGrantClaims(options.grant);
12096
+ if (claims?.challenge !== await linkChallenge(options.verifier)) {
12097
+ throw new Error(
12098
+ "this grant was not issued to this link request - start the link again from this browser"
12099
+ );
12100
+ }
12052
12101
  const { signer, publicKey, accountId } = await resolveRootPair(
12053
12102
  options.rootSecret,
12054
12103
  options.signer
12055
12104
  );
12105
+ if (claims.account !== accountId) {
12106
+ throw new Error(
12107
+ `this grant is for account ${claims.account}, but the root here derives ${accountId} - it was minted for a different account`
12108
+ );
12109
+ }
12056
12110
  const body = await this.request(
12057
12111
  "POST",
12058
12112
  "/api/cloud/accounts/link",
12059
12113
  {
12060
12114
  body: {
12061
12115
  grant: options.grant,
12116
+ verifier: options.verifier,
12062
12117
  root_public_key: publicKey,
12063
12118
  signature: await signAccountLink({
12064
12119
  signer,