@calimero-network/mero-js 26.2.1 → 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.
@@ -383,6 +383,10 @@ export interface AccountLinkCallback {
383
383
  grant?: string;
384
384
  /** The account the grant is for, as the portal echoed it. */
385
385
  accountId?: string;
386
+ /** The cloud login that consented. Show it before spending: anyone holding the handoff URL could have answered it. */
387
+ login?: string;
388
+ /** This browser's secret for the grant. Pass it to {@link CloudClient.linkAccountWithGrant}. */
389
+ verifier?: string;
386
390
  /** `'denied'` when the person cancelled, `'unexpected_callback'` for a grant this browser never asked for. */
387
391
  error?: string;
388
392
  }
@@ -869,7 +873,7 @@ export declare class CloudClient {
869
873
  accountId: string;
870
874
  /** Where to come back to. */
871
875
  callbackUrl: string;
872
- }): AccountLinkHandoff;
876
+ }): Promise<AccountLinkHandoff>;
873
877
  /**
874
878
  * Read the portal's answer out of a callback URL's fragment.
875
879
  *
@@ -893,8 +897,9 @@ export declare class CloudClient {
893
897
  *
894
898
  * The signature is an ordinary account-link signature with the grant as the
895
899
  * nonce, so the same {@link signAccountLink} every other link path uses signs
896
- * this one. Both halves are required: a grant without the key links nothing,
897
- * which is what makes it safe to carry through a browser redirect.
900
+ * this one. The grant is bound to the verifier of the browser that asked for
901
+ * it, and is refused here, before the root signs, unless it carries that
902
+ * verifier's challenge and names this root's account.
898
903
  *
899
904
  * A `403` means the grant was bad, expired, already spent, or signed by a key
900
905
  * that names a different account; `409` that the account belongs to another
@@ -902,6 +907,8 @@ export declare class CloudClient {
902
907
  */
903
908
  linkAccountWithGrant(options: {
904
909
  grant: string;
910
+ /** The verifier {@link readAccountLinkCallback} returned with the grant. */
911
+ verifier: string;
905
912
  /** The root of the account the grant names, 64 hex. */
906
913
  rootSecret?: string;
907
914
  /**
@@ -28,7 +28,7 @@ import { signAccountHaClaim } from './account-ownership.js';
28
28
  import { resolveRoot, resolveRootPair, signAccountLink, signAccountLogin, } from '../account/index.js';
29
29
  import { HTTPError } from '../http-client/web-client.js';
30
30
  import { pathSegment } from '../http-client/path-segment.js';
31
- import { ACCOUNT_HEX, beginLinkState, takeLinkState } from './link-state.js';
31
+ import { ACCOUNT_HEX, VERIFIER_HEX, beginLinkState, linkChallenge, linkGrantClaims, takeLinkVerifier, } from './link-state.js';
32
32
  import { routingProofHeaders, } from './routing-proof.js';
33
33
  /**
34
34
  * The hosted cloud's **API** when a caller names no other.
@@ -827,11 +827,13 @@ export class CloudClient {
827
827
  * Static, so it needs no client and no session: there is nothing to
828
828
  * authenticate as yet.
829
829
  */
830
- static accountLinkHandoff(options) {
830
+ static async accountLinkHandoff(options) {
831
831
  const portal = options.portalUrl.replace(/\/+$/, '');
832
+ const { callbackUrl, challenge } = await beginLinkState(options.accountId, options.callbackUrl);
832
833
  const query = new URLSearchParams({
833
834
  'link-account': options.accountId,
834
- 'callback-url': beginLinkState(options.accountId, options.callbackUrl),
835
+ challenge,
836
+ 'callback-url': callbackUrl,
835
837
  });
836
838
  return { url: `${portal}/?${query.toString()}`, accountId: options.accountId };
837
839
  }
@@ -865,12 +867,16 @@ export class CloudClient {
865
867
  const error = params.get('error');
866
868
  const out = {};
867
869
  if (error)
868
- takeLinkState(search);
869
- if (grant && takeLinkState(search)?.account !== accountId) {
870
- return { error: 'unexpected_callback' };
871
- }
872
- if (grant)
870
+ takeLinkVerifier(search, null);
871
+ if (grant) {
872
+ const verifier = takeLinkVerifier(search, accountId);
873
+ const claims = linkGrantClaims(grant);
874
+ if (!verifier || claims?.account !== accountId)
875
+ return { error: 'unexpected_callback' };
873
876
  out.grant = grant;
877
+ out.login = claims.login;
878
+ out.verifier = verifier;
879
+ }
874
880
  if (accountId)
875
881
  out.accountId = accountId;
876
882
  if (error)
@@ -887,18 +893,31 @@ export class CloudClient {
887
893
  *
888
894
  * The signature is an ordinary account-link signature with the grant as the
889
895
  * nonce, so the same {@link signAccountLink} every other link path uses signs
890
- * this one. Both halves are required: a grant without the key links nothing,
891
- * which is what makes it safe to carry through a browser redirect.
896
+ * this one. The grant is bound to the verifier of the browser that asked for
897
+ * it, and is refused here, before the root signs, unless it carries that
898
+ * verifier's challenge and names this root's account.
892
899
  *
893
900
  * A `403` means the grant was bad, expired, already spent, or signed by a key
894
901
  * that names a different account; `409` that the account belongs to another
895
902
  * login; `402` that the plan is full.
896
903
  */
897
904
  async linkAccountWithGrant(options) {
905
+ if (typeof options.verifier !== 'string' || !VERIFIER_HEX.test(options.verifier)) {
906
+ throw new Error('no verifier for this grant - start the link again from this browser');
907
+ }
908
+ const claims = linkGrantClaims(options.grant);
909
+ if (claims?.challenge !== (await linkChallenge(options.verifier))) {
910
+ throw new Error('this grant was not issued to this link request - start the link again from this browser');
911
+ }
898
912
  const { signer, publicKey, accountId } = await resolveRootPair(options.rootSecret, options.signer);
913
+ if (claims.account !== accountId) {
914
+ throw new Error(`this grant is for account ${claims.account}, but the root here derives ` +
915
+ `${accountId} - it was minted for a different account`);
916
+ }
899
917
  const body = await this.request('POST', '/api/cloud/accounts/link', {
900
918
  body: {
901
919
  grant: options.grant,
920
+ verifier: options.verifier,
902
921
  root_public_key: publicKey,
903
922
  signature: await signAccountLink({
904
923
  signer,
@@ -8,19 +8,21 @@
8
8
  * signature, which only the holder can make. Neither side can do it alone.
9
9
  *
10
10
  * The cloud already implements its half: send a person to
11
- * `?link-account=<id>&callback-url=<url>`, it shows a consent screen and
12
- * redirects back with a **grant** — that login's sealed consent to link one
13
- * named account. This module is the missing other half, and it is deliberately
14
- * small: send them there, then spend what comes back.
11
+ * `?link-account=<id>&challenge=<sha256(verifier)>&callback-url=<url>`, it
12
+ * shows a consent screen and redirects back with a **grant** — that login's
13
+ * sealed consent to link one named account. This module is the missing other
14
+ * half, and it is deliberately small: send them there, then spend what comes
15
+ * back.
15
16
  *
16
17
  * ## Why a grant rather than a session
17
18
  *
18
- * A grant is spendable by anyone holding the account's root, and by nobody
19
- * else. That is what lets the page complete the link while holding **no cloud
20
- * session at all** — and it is why a wallet should never be handed one. A
21
- * session token is a credential for everything that login can do; a grant is a
22
- * credential for exactly one link, to exactly one account, sealed at the moment
23
- * the person read the id on screen and agreed to it.
19
+ * A grant is spendable only with the account's root and the verifier this
20
+ * browser kept when it began the link. That is what lets the page complete the
21
+ * link while holding **no cloud session at all** - and it is why a wallet
22
+ * should never be handed one. A session token is a credential for everything
23
+ * that login can do; a grant is a credential for exactly one link, to exactly
24
+ * one account, sealed at the moment the person read the id on screen and
25
+ * agreed to it.
24
26
  *
25
27
  * ## The account id is chosen by us and read by them
26
28
  *
@@ -45,15 +47,22 @@ export interface BeginCloudLinkOptions {
45
47
  /** The cloud's web origin. Defaults to the hosted one. */
46
48
  cloudWebUrl?: string;
47
49
  }
48
- /** The consent URL to send a person to. Remembers the link so the callback can be matched. */
49
- export declare function cloudLinkUrl(options: BeginCloudLinkOptions): string;
50
+ /**
51
+ * The consent URL to send a person to. Remembers the link, and the verifier its
52
+ * grant will be bound to, so only this browser can match the callback and spend it.
53
+ */
54
+ export declare function cloudLinkUrl(options: BeginCloudLinkOptions): Promise<string>;
50
55
  /** Send the person to the cloud to consent. Does not return. */
51
- export declare function beginCloudLink(options: BeginCloudLinkOptions): never;
56
+ export declare function beginCloudLink(options: BeginCloudLinkOptions): Promise<never>;
52
57
  /** What came back in the fragment, if anything. */
53
58
  export interface CloudLinkCallback {
54
59
  grant: string;
55
60
  /** The account the cloud sealed into the grant. */
56
61
  account: string;
62
+ /** The cloud login that consented. Show it before spending: anyone holding the consent URL could have answered it. */
63
+ login: string;
64
+ /** This browser's secret for the grant, kept from the redirect. Spend it with the grant. */
65
+ verifier: string;
57
66
  }
58
67
  /**
59
68
  * Read a grant out of the current URL fragment, and strip it.
@@ -8,19 +8,21 @@
8
8
  * signature, which only the holder can make. Neither side can do it alone.
9
9
  *
10
10
  * The cloud already implements its half: send a person to
11
- * `?link-account=<id>&callback-url=<url>`, it shows a consent screen and
12
- * redirects back with a **grant** — that login's sealed consent to link one
13
- * named account. This module is the missing other half, and it is deliberately
14
- * small: send them there, then spend what comes back.
11
+ * `?link-account=<id>&challenge=<sha256(verifier)>&callback-url=<url>`, it
12
+ * shows a consent screen and redirects back with a **grant** — that login's
13
+ * sealed consent to link one named account. This module is the missing other
14
+ * half, and it is deliberately small: send them there, then spend what comes
15
+ * back.
15
16
  *
16
17
  * ## Why a grant rather than a session
17
18
  *
18
- * A grant is spendable by anyone holding the account's root, and by nobody
19
- * else. That is what lets the page complete the link while holding **no cloud
20
- * session at all** — and it is why a wallet should never be handed one. A
21
- * session token is a credential for everything that login can do; a grant is a
22
- * credential for exactly one link, to exactly one account, sealed at the moment
23
- * the person read the id on screen and agreed to it.
19
+ * A grant is spendable only with the account's root and the verifier this
20
+ * browser kept when it began the link. That is what lets the page complete the
21
+ * link while holding **no cloud session at all** - and it is why a wallet
22
+ * should never be handed one. A session token is a credential for everything
23
+ * that login can do; a grant is a credential for exactly one link, to exactly
24
+ * one account, sealed at the moment the person read the id on screen and
25
+ * agreed to it.
24
26
  *
25
27
  * ## The account id is chosen by us and read by them
26
28
  *
@@ -32,19 +34,24 @@
32
34
  */
33
35
  import { CloudClient } from './cloud-client.js';
34
36
  import { resolveRoot } from '../account/account.js';
35
- import { beginLinkState, takeLinkState, withoutLinkState } from './link-state.js';
37
+ import { beginLinkState, linkGrantClaims, takeLinkVerifier, withoutLinkState } from './link-state.js';
36
38
  /** Where the consent screen lives. The web app, not the manager API. */
37
39
  const DEFAULT_CLOUD_WEB_URL = 'https://cloud.calimero.network';
38
- /** The consent URL to send a person to. Remembers the link so the callback can be matched. */
39
- export function cloudLinkUrl(options) {
40
+ /**
41
+ * The consent URL to send a person to. Remembers the link, and the verifier its
42
+ * grant will be bound to, so only this browser can match the callback and spend it.
43
+ */
44
+ export async function cloudLinkUrl(options) {
40
45
  const url = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
46
+ const { callbackUrl, challenge } = await beginLinkState(options.accountId, options.returnTo);
41
47
  url.searchParams.set('link-account', options.accountId);
42
- url.searchParams.set('callback-url', beginLinkState(options.accountId, options.returnTo));
48
+ url.searchParams.set('challenge', challenge);
49
+ url.searchParams.set('callback-url', callbackUrl);
43
50
  return url.toString();
44
51
  }
45
52
  /** Send the person to the cloud to consent. Does not return. */
46
- export function beginCloudLink(options) {
47
- window.location.assign(cloudLinkUrl(options));
53
+ export async function beginCloudLink(options) {
54
+ window.location.assign(await cloudLinkUrl(options));
48
55
  // `assign` does not stop execution, and a caller that kept going would act on
49
56
  // a page already being replaced.
50
57
  throw new Error('redirecting to the cloud to authorise this link');
@@ -60,7 +67,7 @@ export function readCloudLinkCallback(location = window.location) {
60
67
  const params = new URLSearchParams(location.hash.replace(/^#/, ''));
61
68
  const error = params.get('error');
62
69
  if (error) {
63
- takeLinkState(location.search ?? '');
70
+ takeLinkVerifier(location.search ?? '', null);
64
71
  stripFragment();
65
72
  throw new Error(error === 'denied'
66
73
  ? 'the account link was declined at the cloud'
@@ -70,14 +77,16 @@ export function readCloudLinkCallback(location = window.location) {
70
77
  const account = params.get('account');
71
78
  if (!grant || !account)
72
79
  return null;
73
- if (takeLinkState(location.search ?? '')?.account !== account) {
80
+ const verifier = takeLinkVerifier(location.search ?? '', account);
81
+ const claims = linkGrantClaims(grant);
82
+ if (!verifier || claims?.account !== account) {
74
83
  stripFragment();
75
84
  throw new Error('this account link answers a request this browser did not make - start the link again from here');
76
85
  }
77
86
  // Consumed once. Leaving it in the bar means a reload replays a spent grant
78
87
  // and reports a confusing failure instead of the link that already worked.
79
88
  stripFragment();
80
- return { grant, account };
89
+ return { grant, account, login: claims.login, verifier };
81
90
  }
82
91
  function stripFragment() {
83
92
  if (typeof history === 'undefined' || !history.replaceState)
@@ -104,5 +113,9 @@ export async function completeCloudLink(options) {
104
113
  cloudBaseUrl: options.cloudBaseUrl,
105
114
  fetch: options.fetch,
106
115
  });
107
- return cloud.linkAccountWithGrant({ grant: options.grant, signer: root.signer });
116
+ return cloud.linkAccountWithGrant({
117
+ grant: options.grant,
118
+ verifier: options.verifier,
119
+ signer: root.signer,
120
+ });
108
121
  }
@@ -3,15 +3,34 @@
3
3
  * not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
4
4
  */
5
5
  export declare const ACCOUNT_HEX: RegExp;
6
+ export declare const VERIFIER_HEX: RegExp;
6
7
  /** Remember `payload` in the `key` store under a fresh random state, and return the state. */
7
8
  export declare function beginState<T extends object>(key: string, payload: T): string;
8
9
  /** Removes and returns what `state` was begun with, or null when this browser did not begin it. */
9
10
  export declare function takeState<T extends object>(key: string, state: string | null | undefined): T | null;
10
- /** Remember a link for `account` and return `returnTo` carrying its state. */
11
- export declare function beginLinkState(account: string, returnTo: string): string;
12
- /** Removes and returns the link a callback answers, or null when this browser did not begin it. */
13
- export declare function takeLinkState(search: string): {
11
+ /** What a link grant claims, read without its seal, which only the cloud can check. */
12
+ export interface LinkGrantClaims {
13
+ /** The cloud login that consented. */
14
+ login: string;
14
15
  account: string;
15
- } | null;
16
+ challenge: string;
17
+ }
18
+ /** The challenge a verifier commits to: sha256 of its bytes, hex. */
19
+ export declare function linkChallenge(verifier: string): Promise<string>;
20
+ /**
21
+ * Remember a link for `account` under a fresh verifier. Returns `returnTo`
22
+ * carrying its state, and the challenge to send the cloud in place of the verifier.
23
+ */
24
+ export declare function beginLinkState(account: string, returnTo: string): Promise<{
25
+ callbackUrl: string;
26
+ challenge: string;
27
+ }>;
28
+ /**
29
+ * Removes the link a callback answers and returns its verifier, or null when
30
+ * this browser did not begin a link for `account` with this state.
31
+ */
32
+ export declare function takeLinkVerifier(search: string, account: string | null): string | null;
33
+ /** The claims of a link grant, or null when `grant` is not one. */
34
+ export declare function linkGrantClaims(grant: string): LinkGrantClaims | null;
16
35
  /** The query with the link state removed, for rewriting the address bar. */
17
36
  export declare function withoutLinkState(search: string): string;
@@ -2,11 +2,15 @@
2
2
  * Redirects this browser began, so a callback can be matched to one. `localStorage`,
3
3
  * not `sessionStorage`: consent opens in a `noopener` tab with empty session storage.
4
4
  */
5
+ import { hex } from "../crypto/internal.js";
5
6
  const KEY = "calimero.cloud.link.pending";
6
7
  const STATE_PARAM = "link_state";
7
8
  const LIFETIME_MS = 10 * 60 * 1000; // a grant lives 5 minutes, plus time to consent
8
9
  export const ACCOUNT_HEX = /^[0-9a-f]{64}$/;
9
10
  const MAX_PENDING = 20; // bounds storage when a page builds a consent URL on every render
11
+ const VERIFIER_BYTES = 32; // the cloud refuses any other length
12
+ export const VERIFIER_HEX = /^[0-9a-f]{64}$/;
13
+ const LINK_GRANT_USE = "account-link-grant";
10
14
  function readPending(key, now) {
11
15
  try {
12
16
  const raw = localStorage.getItem(key);
@@ -29,8 +33,7 @@ function writePending(key, pending) {
29
33
  export function beginState(key, payload) {
30
34
  const now = Date.now();
31
35
  const pending = readPending(key, now);
32
- const bytes = crypto.getRandomValues(new Uint8Array(16));
33
- const state = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
36
+ const state = hex(crypto.getRandomValues(new Uint8Array(16)));
34
37
  pending[state] = { ...payload, expiresAt: now + LIFETIME_MS };
35
38
  writePending(key, Object.fromEntries(Object.entries(pending).slice(-MAX_PENDING)));
36
39
  return state;
@@ -45,18 +48,52 @@ export function takeState(key, state) {
45
48
  writePending(key, pending);
46
49
  return entry;
47
50
  }
48
- /** Remember a link for `account` and return `returnTo` carrying its state. */
49
- export function beginLinkState(account, returnTo) {
51
+ /** The challenge a verifier commits to: sha256 of its bytes, hex. */
52
+ export async function linkChallenge(verifier) {
53
+ const bytes = Uint8Array.from(verifier.match(/../g) ?? [], (b) => parseInt(b, 16));
54
+ return hex(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)));
55
+ }
56
+ /**
57
+ * Remember a link for `account` under a fresh verifier. Returns `returnTo`
58
+ * carrying its state, and the challenge to send the cloud in place of the verifier.
59
+ */
60
+ export async function beginLinkState(account, returnTo) {
50
61
  if (!ACCOUNT_HEX.test(account)) {
51
62
  throw new Error("accountId must be 64 lowercase hex characters");
52
63
  }
53
64
  const url = new URL(returnTo);
54
- url.searchParams.set(STATE_PARAM, beginState(KEY, { account }));
55
- return url.toString();
65
+ const verifier = hex(crypto.getRandomValues(new Uint8Array(VERIFIER_BYTES)));
66
+ const challenge = await linkChallenge(verifier);
67
+ url.searchParams.set(STATE_PARAM, beginState(KEY, { account, verifier }));
68
+ return { callbackUrl: url.toString(), challenge };
69
+ }
70
+ /**
71
+ * Removes the link a callback answers and returns its verifier, or null when
72
+ * this browser did not begin a link for `account` with this state.
73
+ */
74
+ export function takeLinkVerifier(search, account) {
75
+ const pending = takeState(KEY, new URLSearchParams(search).get(STATE_PARAM));
76
+ if (!pending || pending.account !== account)
77
+ return null;
78
+ return typeof pending.verifier === "string" && VERIFIER_HEX.test(pending.verifier)
79
+ ? pending.verifier
80
+ : null;
56
81
  }
57
- /** Removes and returns the link a callback answers, or null when this browser did not begin it. */
58
- export function takeLinkState(search) {
59
- return takeState(KEY, new URLSearchParams(search).get(STATE_PARAM));
82
+ /** The claims of a link grant, or null when `grant` is not one. */
83
+ export function linkGrantClaims(grant) {
84
+ try {
85
+ const body = grant.split(".")[0].replace(/-/g, "+").replace(/_/g, "/");
86
+ const claims = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(body), (c) => c.charCodeAt(0))));
87
+ const { use, sub, acc, chal } = claims;
88
+ if (use !== LINK_GRANT_USE)
89
+ return null;
90
+ if (typeof sub !== "string" || typeof acc !== "string" || typeof chal !== "string")
91
+ return null;
92
+ return { login: sub, account: acc, challenge: chal };
93
+ }
94
+ catch {
95
+ return null;
96
+ }
60
97
  }
61
98
  /** The query with the link state removed, for rewriting the address bar. */
62
99
  export function withoutLinkState(search) {