@calimero-network/mero-js 22.6.0 → 22.8.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.
Files changed (76) hide show
  1. package/dist/account/account.d.ts +99 -4
  2. package/dist/account/account.js +0 -0
  3. package/dist/account/index.d.ts +6 -2
  4. package/dist/account/index.js +3 -1
  5. package/dist/admin-api/admin-client.d.ts +138 -2
  6. package/dist/admin-api/admin-client.js +237 -1
  7. package/dist/admin-api/admin-types.d.ts +159 -5
  8. package/dist/capabilities.d.ts +42 -0
  9. package/dist/capabilities.js +64 -0
  10. package/dist/cloud/cloud-client.d.ts +38 -8
  11. package/dist/cloud/cloud-client.js +38 -21
  12. package/dist/cloud/connect.d.ts +101 -2
  13. package/dist/cloud/connect.js +84 -8
  14. package/dist/cloud/enrol-redirect.d.ts +195 -0
  15. package/dist/cloud/enrol-redirect.js +236 -0
  16. package/dist/cloud/index.d.ts +6 -2
  17. package/dist/cloud/index.js +7 -1
  18. package/dist/cloud/link-redirect.d.ts +85 -0
  19. package/dist/cloud/link-redirect.js +105 -0
  20. package/dist/crypto/internal.d.ts +15 -0
  21. package/dist/crypto/internal.js +18 -0
  22. package/dist/device-cert/device-cert.d.ts +85 -2
  23. package/dist/device-cert/device-cert.js +100 -9
  24. package/dist/device-cert/index.d.ts +2 -2
  25. package/dist/device-cert/index.js +1 -1
  26. package/dist/events/sse.d.ts +22 -2
  27. package/dist/events/sse.js +20 -11
  28. package/dist/http-client/http-types.d.ts +18 -0
  29. package/dist/http-client/web-client.js +21 -1
  30. package/dist/index.browser.mjs +2 -2
  31. package/dist/index.browser.mjs.map +4 -4
  32. package/dist/index.cjs +1999 -536
  33. package/dist/index.cjs.map +4 -4
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +17 -3
  36. package/dist/index.mjs +1999 -536
  37. package/dist/index.mjs.map +4 -4
  38. package/dist/login/login.d.ts +11 -1
  39. package/dist/login/login.js +5 -4
  40. package/dist/login/session.d.ts +21 -5
  41. package/dist/login/session.js +7 -15
  42. package/dist/namespace-op/namespace-op.d.ts +14 -2
  43. package/dist/namespace-op/namespace-op.js +9 -5
  44. package/dist/relay/index.d.ts +3 -3
  45. package/dist/relay/index.js +1 -1
  46. package/dist/relay/nonce-source.d.ts +135 -0
  47. package/dist/relay/nonce-source.js +187 -19
  48. package/dist/relay/relay-client.d.ts +148 -5
  49. package/dist/relay/relay-client.js +187 -7
  50. package/dist/relay-attestation/attest-node-key.d.ts +66 -0
  51. package/dist/relay-attestation/attest-node-key.js +110 -0
  52. package/dist/relay-attestation/index.d.ts +1 -0
  53. package/dist/relay-attestation/index.js +1 -0
  54. package/dist/request-proof/authorizer.d.ts +48 -0
  55. package/dist/request-proof/authorizer.js +65 -0
  56. package/dist/request-proof/index.d.ts +4 -0
  57. package/dist/request-proof/index.js +2 -0
  58. package/dist/request-proof/request-proof.d.ts +56 -0
  59. package/dist/request-proof/request-proof.js +111 -0
  60. package/dist/rpc/index.d.ts +21 -0
  61. package/dist/rpc/index.js +20 -0
  62. package/dist/transport/index.d.ts +6 -0
  63. package/dist/transport/index.js +3 -0
  64. package/dist/transport/mero-client.d.ts +286 -0
  65. package/dist/transport/mero-client.js +379 -0
  66. package/dist/transport/relay-observer.d.ts +152 -0
  67. package/dist/transport/relay-observer.js +173 -0
  68. package/dist/transport/relay-transport.d.ts +73 -0
  69. package/dist/transport/relay-transport.js +94 -0
  70. package/dist/transport/types.d.ts +100 -0
  71. package/dist/transport/types.js +32 -0
  72. package/dist/warrant/governance-op.d.ts +47 -1
  73. package/dist/warrant/governance-op.js +62 -2
  74. package/dist/warrant/index.d.ts +2 -2
  75. package/dist/warrant/index.js +1 -1
  76. package/package.json +1 -1
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Linking an account to a cloud login **from a browser**.
3
+ *
4
+ * The cloud's own accounts screen says it plainly: *"The cloud cannot link an
5
+ * account on your behalf: proving you own it is a signature by the account root
6
+ * key, which the cloud never holds."* So the link needs two things that live in
7
+ * different places — a cloud session, which only Google can mint, and a root
8
+ * signature, which only the holder can make. Neither side can do it alone.
9
+ *
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.
15
+ *
16
+ * ## Why a grant rather than a session
17
+ *
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.
24
+ *
25
+ * ## The account id is chosen by us and read by them
26
+ *
27
+ * The cloud seals the account into the grant at consent time, and its own note
28
+ * says why: the server can check that the key which later signs derives that
29
+ * same account, but *"it cannot know whether the person meant this account"*.
30
+ * The consent screen is that check. So show the same id the person will see,
31
+ * before sending them — a wallet that hides it has removed the only safeguard.
32
+ */
33
+ import type { CloudAccountLink } from './cloud-client.js';
34
+ import { type RootSource } from '../account/account.js';
35
+ export interface BeginCloudLinkOptions {
36
+ /** The account to link — 64 hex. Show this to the person before redirecting. */
37
+ accountId: string;
38
+ /**
39
+ * Where the cloud sends them back. Must be `https:`, or `http:` on localhost.
40
+ *
41
+ * The grant returns in the URL **fragment**, which is never sent to a server,
42
+ * so it does not land in anyone's access log on the way.
43
+ */
44
+ returnTo: string;
45
+ /** The cloud's web origin. Defaults to the hosted one. */
46
+ cloudWebUrl?: string;
47
+ }
48
+ /** The consent URL to send a person to. */
49
+ export declare function cloudLinkUrl(options: BeginCloudLinkOptions): string;
50
+ /** Send the person to the cloud to consent. Does not return. */
51
+ export declare function beginCloudLink(options: BeginCloudLinkOptions): never;
52
+ /** What came back in the fragment, if anything. */
53
+ export interface CloudLinkCallback {
54
+ grant: string;
55
+ /** The account the cloud sealed into the grant. */
56
+ account: string;
57
+ }
58
+ /**
59
+ * Read a grant out of the current URL fragment, and strip it.
60
+ *
61
+ * Returns `null` when this is an ordinary page load, so a caller can run it
62
+ * unconditionally at startup. Throws when the person declined, because that is
63
+ * an outcome the app has to show rather than a state to wait in.
64
+ */
65
+ export declare function readCloudLinkCallback(location?: {
66
+ hash: string;
67
+ }): CloudLinkCallback | null;
68
+ export interface CompleteCloudLinkOptions extends CloudLinkCallback {
69
+ /** The account root — a 64-hex secret, or a Signer over a key you cannot read. */
70
+ root: RootSource;
71
+ /** The **manager** API base, e.g. `https://manager.cloud.calimero.network`. */
72
+ cloudBaseUrl?: string;
73
+ fetch?: typeof fetch;
74
+ }
75
+ /**
76
+ * Spend the grant: sign it with the root and hand it back to the cloud.
77
+ *
78
+ * Anonymous by construction — the client is built with no session, because the
79
+ * whole point is that the page never needed one.
80
+ *
81
+ * @throws if the grant names a different account than the root derives, before
82
+ * anything is sent. The cloud checks this too, but failing here says which two
83
+ * accounts disagreed rather than returning a bare 403.
84
+ */
85
+ export declare function completeCloudLink(options: CompleteCloudLinkOptions): Promise<CloudAccountLink>;
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Linking an account to a cloud login **from a browser**.
3
+ *
4
+ * The cloud's own accounts screen says it plainly: *"The cloud cannot link an
5
+ * account on your behalf: proving you own it is a signature by the account root
6
+ * key, which the cloud never holds."* So the link needs two things that live in
7
+ * different places — a cloud session, which only Google can mint, and a root
8
+ * signature, which only the holder can make. Neither side can do it alone.
9
+ *
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.
15
+ *
16
+ * ## Why a grant rather than a session
17
+ *
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.
24
+ *
25
+ * ## The account id is chosen by us and read by them
26
+ *
27
+ * The cloud seals the account into the grant at consent time, and its own note
28
+ * says why: the server can check that the key which later signs derives that
29
+ * same account, but *"it cannot know whether the person meant this account"*.
30
+ * The consent screen is that check. So show the same id the person will see,
31
+ * before sending them — a wallet that hides it has removed the only safeguard.
32
+ */
33
+ import { CloudClient } from './cloud-client.js';
34
+ import { resolveRoot } from '../account/account.js';
35
+ /** Where the consent screen lives. The web app, not the manager API. */
36
+ const DEFAULT_CLOUD_WEB_URL = 'https://cloud.calimero.network';
37
+ /** The consent URL to send a person to. */
38
+ export function cloudLinkUrl(options) {
39
+ if (!/^[0-9a-f]{64}$/.test(options.accountId)) {
40
+ throw new Error('accountId must be 64 lowercase hex characters');
41
+ }
42
+ const url = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
43
+ url.searchParams.set('link-account', options.accountId);
44
+ url.searchParams.set('callback-url', options.returnTo);
45
+ return url.toString();
46
+ }
47
+ /** Send the person to the cloud to consent. Does not return. */
48
+ export function beginCloudLink(options) {
49
+ window.location.assign(cloudLinkUrl(options));
50
+ // `assign` does not stop execution, and a caller that kept going would act on
51
+ // a page already being replaced.
52
+ throw new Error('redirecting to the cloud to authorise this link');
53
+ }
54
+ /**
55
+ * Read a grant out of the current URL fragment, and strip it.
56
+ *
57
+ * Returns `null` when this is an ordinary page load, so a caller can run it
58
+ * unconditionally at startup. Throws when the person declined, because that is
59
+ * an outcome the app has to show rather than a state to wait in.
60
+ */
61
+ export function readCloudLinkCallback(location = window.location) {
62
+ const params = new URLSearchParams(location.hash.replace(/^#/, ''));
63
+ const error = params.get('error');
64
+ if (error) {
65
+ stripFragment();
66
+ throw new Error(error === 'denied'
67
+ ? 'the account link was declined at the cloud'
68
+ : `the cloud refused this link: ${error}`);
69
+ }
70
+ const grant = params.get('grant');
71
+ const account = params.get('account');
72
+ if (!grant || !account)
73
+ return null;
74
+ // Consumed once. Leaving it in the bar means a reload replays a spent grant
75
+ // and reports a confusing failure instead of the link that already worked.
76
+ stripFragment();
77
+ return { grant, account };
78
+ }
79
+ function stripFragment() {
80
+ if (typeof history === 'undefined' || !history.replaceState)
81
+ return;
82
+ history.replaceState(null, '', location.pathname + location.search);
83
+ }
84
+ /**
85
+ * Spend the grant: sign it with the root and hand it back to the cloud.
86
+ *
87
+ * Anonymous by construction — the client is built with no session, because the
88
+ * whole point is that the page never needed one.
89
+ *
90
+ * @throws if the grant names a different account than the root derives, before
91
+ * anything is sent. The cloud checks this too, but failing here says which two
92
+ * accounts disagreed rather than returning a bare 403.
93
+ */
94
+ export async function completeCloudLink(options) {
95
+ const root = await resolveRoot(options.root);
96
+ if (root.accountId !== options.account) {
97
+ throw new Error(`this grant is for account ${options.account}, but the root here derives ` +
98
+ `${root.accountId} — it was minted for a different account`);
99
+ }
100
+ const cloud = new CloudClient({
101
+ cloudBaseUrl: options.cloudBaseUrl,
102
+ fetch: options.fetch,
103
+ });
104
+ return cloud.linkAccountWithGrant({ grant: options.grant, signer: root.signer });
105
+ }
@@ -31,6 +31,21 @@ export declare function concat(...parts: Uint8Array[]): Uint8Array;
31
31
  */
32
32
  export declare function domainHash(domain: Uint8Array, parts: Uint8Array[]): Promise<Uint8Array>;
33
33
  export declare function importSigningKey(secretHex: string): Promise<CryptoKey>;
34
+ /**
35
+ * Check an ed25519 signature against a public key, both raw.
36
+ *
37
+ * The one primitive here that consumes rather than produces a signature: a page
38
+ * handed a credential by another origin has to check it before it acts on it,
39
+ * and "the node will reject it later" is not the same safeguard — by then the
40
+ * page has already stored an account it does not control.
41
+ *
42
+ * `raw` import is the public-key counterpart of the PKCS#8 import above and is
43
+ * available on exactly the runtimes that can sign, so a runtime that got this
44
+ * far will not fail here for lack of support.
45
+ *
46
+ * @param publicKey 64 hex, the key the signature must verify against
47
+ */
48
+ export declare function verifySignature(publicKey: string, signature: Uint8Array, payload: Uint8Array): Promise<boolean>;
34
49
  /**
35
50
  * The public half of an ed25519 seed.
36
51
  *
@@ -97,6 +97,24 @@ export async function importSigningKey(secretHex) {
97
97
  detail);
98
98
  }
99
99
  }
100
+ /**
101
+ * Check an ed25519 signature against a public key, both raw.
102
+ *
103
+ * The one primitive here that consumes rather than produces a signature: a page
104
+ * handed a credential by another origin has to check it before it acts on it,
105
+ * and "the node will reject it later" is not the same safeguard — by then the
106
+ * page has already stored an account it does not control.
107
+ *
108
+ * `raw` import is the public-key counterpart of the PKCS#8 import above and is
109
+ * available on exactly the runtimes that can sign, so a runtime that got this
110
+ * far will not fail here for lack of support.
111
+ *
112
+ * @param publicKey 64 hex, the key the signature must verify against
113
+ */
114
+ export async function verifySignature(publicKey, signature, payload) {
115
+ const key = await crypto.subtle.importKey('raw', fromHex(publicKey, 'publicKey', 32), { name: 'Ed25519' }, false, ['verify']);
116
+ return crypto.subtle.verify({ name: 'Ed25519' }, key, signature, payload);
117
+ }
100
118
  /**
101
119
  * The public half of an ed25519 seed.
102
120
  *
@@ -1,7 +1,25 @@
1
+ import { type Signer } from '../signer/signer.js';
1
2
  /** What a root certifies about one device. */
2
3
  export interface DeviceCertInput {
3
- /** The account root's signing secret, 64 hex. Signs the certificate. */
4
- rootSecret: string;
4
+ /**
5
+ * The account root's signing secret, 64 hex. Signs the certificate.
6
+ *
7
+ * Mutually exclusive with {@link DeviceCertInput.signer}, and the weaker of
8
+ * the two in a browser: this is the one key in the system with nothing above
9
+ * it to revoke it, so a page that can read it can take the account for good.
10
+ * Prefer `signer` anywhere the root is held rather than loaded.
11
+ */
12
+ rootSecret?: string;
13
+ /**
14
+ * The account root's signer — use instead of {@link DeviceCertInput.rootSecret}
15
+ * when the root is a key this page may use and cannot read.
16
+ *
17
+ * This is what lets the certifying page keep its root as a non-extractable
18
+ * `CryptoKey`: {@link accountRootSignerFromPhrase} hands one back, and script
19
+ * injected into the page can then mint certificates only while the page is
20
+ * open, rather than walking off with the account.
21
+ */
22
+ signer?: Signer;
5
23
  /** The device being certified, 64 hex — see {@link mintDeviceId}. */
6
24
  device: string;
7
25
  /** The key that device signs with, 64 hex. */
@@ -88,5 +106,70 @@ export declare function accountProofBytes(input: {
88
106
  * than accepting a value it would then have to ignore.
89
107
  */
90
108
  export declare function signDeviceCert(input: DeviceCertInput): Promise<string>;
109
+ /** A credential read back apart, field for field. */
110
+ export interface DeviceCredential {
111
+ /** The account root that signed, 64 hex — `AccountGenesis::root_sign_pk`. */
112
+ rootPublicKey: string;
113
+ /** The account the certificate names, 64 hex. */
114
+ account: string;
115
+ /** The device it certifies, 64 hex. */
116
+ device: string;
117
+ /** That device's ed25519 signing key, 64 hex. */
118
+ signPublicKey: string;
119
+ /** That device's X25519 key-delivery key, 64 hex. */
120
+ kemPublicKey: string;
121
+ keyEpoch: number;
122
+ deviceEpoch: number;
123
+ /** The root's signature over {@link deviceCertPayload}, 128 hex. */
124
+ signature: string;
125
+ }
126
+ /**
127
+ * Read an `AccountProof<DeviceCert>` back apart — the inverse of
128
+ * {@link accountProofBytes}, and deliberately in the same file as it.
129
+ *
130
+ * A decoder that lives away from its encoder drifts: the layout is only checked
131
+ * by a peer rejecting a signature, which points at the signature rather than at
132
+ * whichever copy went stale.
133
+ *
134
+ * This checks the *shape* and nothing else — it says what the bytes claim, not
135
+ * whether the claim is true. {@link verifyDeviceCredential} is the one that
136
+ * answers that, and it is the one a caller acting on a credential from another
137
+ * origin wants.
138
+ *
139
+ * @throws on a wrong genesis version, a non-empty handoff chain, or trailing
140
+ * bytes. A non-empty chain is refused rather than skipped: verifying a handed-off
141
+ * root means walking the chain, and a decoder that returned the statement while
142
+ * ignoring who was entitled to sign it would hand a verifier the wrong root.
143
+ */
144
+ export declare function parseDeviceCredential(credential: string): DeviceCredential;
145
+ /**
146
+ * Check that a credential says what it claims: the root signed it, and the
147
+ * account it names is the one that root derives.
148
+ *
149
+ * Both halves matter and neither implies the other. The signature says the
150
+ * genesis root consented to these exact keys; the account derivation says the
151
+ * certificate cannot have been re-pointed at a different account while keeping a
152
+ * signature that verifies. A caller that checked only the first would accept a
153
+ * certificate naming an account whose root never signed anything.
154
+ *
155
+ * What it does **not** establish is that the account is one the caller wanted.
156
+ * Anyone can mint a root offline and certify any public key with it, so a
157
+ * credential arriving from elsewhere proves a *consistent* account, not *your*
158
+ * account — see `cloud/enrol-redirect.ts` for the binding that closes that.
159
+ *
160
+ * @throws with which check failed, rather than returning false — every failure
161
+ * here is a credential that must not be stored, and a boolean gets ignored.
162
+ */
163
+ export declare function verifyDeviceCredential(credential: string): Promise<DeviceCredential>;
91
164
  /** The account this root owns — the content address of its genesis. */
92
165
  export declare function accountForRoot(rootSecret: string): Promise<string>;
166
+ /**
167
+ * The same account id, from the root's **public** half.
168
+ *
169
+ * The form a root that cannot be exported needs. It is also the honest shape of
170
+ * the derivation: an account id is a function of the public key, and asking for
171
+ * the secret only ever meant "let me derive the public key first".
172
+ *
173
+ * @param rootPublicKey the root's ed25519 public key, 64 hex
174
+ */
175
+ export declare function accountForRootPublicKey(rootPublicKey: string): Promise<string>;
@@ -15,7 +15,8 @@
15
15
  * - `DeviceId::mint` — `crates/primitives/src/identity.rs`
16
16
  * - `AccountProof` / `AccountGenesis` borsh layout — `crates/account/src/{signed,account}.rs`
17
17
  */
18
- import { concat, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u32le, } from '../crypto/internal.js';
18
+ import { concat, derivePublicKey, domainHash, fromHex, hex, u32le, verifySignature, } from '../crypto/internal.js';
19
+ import { resolveSigner } from '../signer/signer.js';
19
20
  const CERT_DOMAIN = new TextEncoder().encode('calimero.device.cert.v1');
20
21
  const ACCOUNT_ID_DOMAIN = new TextEncoder().encode('calimero.account.genesis.v1');
21
22
  const DEVICE_ID_DOMAIN = new TextEncoder().encode('calimero.device.id.v1');
@@ -31,6 +32,8 @@ const DEVICE_ID_DOMAIN = new TextEncoder().encode('calimero.device.id.v1');
31
32
  * only the tag was stale.
32
33
  */
33
34
  const ACCOUNT_GENESIS_VERSION = 2;
35
+ /** Byte length of an `AccountProof<DeviceCert>` with an empty handoff chain. */
36
+ const ACCOUNT_PROOF_BYTES = 237;
34
37
  /**
35
38
  * Mint a device id: `H(DEVICE_ID_DOMAIN, account ‖ nonce)`.
36
39
  *
@@ -104,10 +107,11 @@ export function accountProofBytes(input) {
104
107
  * than accepting a value it would then have to ignore.
105
108
  */
106
109
  export async function signDeviceCert(input) {
107
- const rootPublicKey = await derivePublicKey(input.rootSecret);
108
- const account = hex(await domainHash(ACCOUNT_ID_DOMAIN, [
109
- concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey),
110
- ]));
110
+ const signer = await resolveSigner(input.rootSecret, input.signer, 'rootSecret');
111
+ // The signer names its own key, so the account is read off it rather than
112
+ // derived from material this function may not have.
113
+ const rootPublicKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
114
+ const account = await accountForRootPublicKey(signer.publicKey);
111
115
  const keyEpoch = 0;
112
116
  const payload = await deviceCertPayload({
113
117
  account,
@@ -117,8 +121,7 @@ export async function signDeviceCert(input) {
117
121
  keyEpoch,
118
122
  deviceEpoch: input.deviceEpoch,
119
123
  });
120
- const key = await importSigningKey(input.rootSecret);
121
- const signature = new Uint8Array(await crypto.subtle.sign('Ed25519', key, payload));
124
+ const signature = await signer.sign(payload);
122
125
  const credential = accountProofBytes({
123
126
  rootPublicKey,
124
127
  account,
@@ -131,10 +134,98 @@ export async function signDeviceCert(input) {
131
134
  });
132
135
  return hex(credential);
133
136
  }
137
+ /**
138
+ * Read an `AccountProof<DeviceCert>` back apart — the inverse of
139
+ * {@link accountProofBytes}, and deliberately in the same file as it.
140
+ *
141
+ * A decoder that lives away from its encoder drifts: the layout is only checked
142
+ * by a peer rejecting a signature, which points at the signature rather than at
143
+ * whichever copy went stale.
144
+ *
145
+ * This checks the *shape* and nothing else — it says what the bytes claim, not
146
+ * whether the claim is true. {@link verifyDeviceCredential} is the one that
147
+ * answers that, and it is the one a caller acting on a credential from another
148
+ * origin wants.
149
+ *
150
+ * @throws on a wrong genesis version, a non-empty handoff chain, or trailing
151
+ * bytes. A non-empty chain is refused rather than skipped: verifying a handed-off
152
+ * root means walking the chain, and a decoder that returned the statement while
153
+ * ignoring who was entitled to sign it would hand a verifier the wrong root.
154
+ */
155
+ export function parseDeviceCredential(credential) {
156
+ const bytes = fromHex(credential, 'credential',
157
+ // Fixed-width with an empty chain: 1 + 32 + 4 + 32*4 + 4 + 4 + 64.
158
+ ACCOUNT_PROOF_BYTES);
159
+ if (bytes[0] !== ACCOUNT_GENESIS_VERSION) {
160
+ throw new Error(`this credential has account genesis version ${bytes[0]}, not ` +
161
+ `${ACCOUNT_GENESIS_VERSION} — it names a different account than its root ` +
162
+ 'key derives here, and core would refuse it');
163
+ }
164
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
165
+ const chainLength = view.getUint32(33, true);
166
+ if (chainLength !== 0) {
167
+ throw new Error(`this credential carries a ${chainLength}-handoff root chain, which this ` +
168
+ 'SDK cannot verify yet — the signing root is not the genesis root, so ' +
169
+ 'checking it means walking the chain');
170
+ }
171
+ const at = (offset, length) => hex(bytes.subarray(offset, offset + length));
172
+ return {
173
+ rootPublicKey: at(1, 32),
174
+ account: at(37, 32),
175
+ device: at(69, 32),
176
+ signPublicKey: at(101, 32),
177
+ kemPublicKey: at(133, 32),
178
+ keyEpoch: view.getUint32(165, true),
179
+ deviceEpoch: view.getUint32(169, true),
180
+ signature: at(173, 64),
181
+ };
182
+ }
183
+ /**
184
+ * Check that a credential says what it claims: the root signed it, and the
185
+ * account it names is the one that root derives.
186
+ *
187
+ * Both halves matter and neither implies the other. The signature says the
188
+ * genesis root consented to these exact keys; the account derivation says the
189
+ * certificate cannot have been re-pointed at a different account while keeping a
190
+ * signature that verifies. A caller that checked only the first would accept a
191
+ * certificate naming an account whose root never signed anything.
192
+ *
193
+ * What it does **not** establish is that the account is one the caller wanted.
194
+ * Anyone can mint a root offline and certify any public key with it, so a
195
+ * credential arriving from elsewhere proves a *consistent* account, not *your*
196
+ * account — see `cloud/enrol-redirect.ts` for the binding that closes that.
197
+ *
198
+ * @throws with which check failed, rather than returning false — every failure
199
+ * here is a credential that must not be stored, and a boolean gets ignored.
200
+ */
201
+ export async function verifyDeviceCredential(credential) {
202
+ const parsed = parseDeviceCredential(credential);
203
+ const derived = await accountForRootPublicKey(parsed.rootPublicKey);
204
+ if (derived !== parsed.account) {
205
+ throw new Error(`this credential names account ${parsed.account}, but its root key ` +
206
+ `derives ${derived} — the certificate was re-pointed at another account`);
207
+ }
208
+ const signed = await verifySignature(parsed.rootPublicKey, fromHex(parsed.signature, 'signature', 64), await deviceCertPayload(parsed));
209
+ if (!signed) {
210
+ throw new Error(`the root key of account ${parsed.account} did not sign this certificate`);
211
+ }
212
+ return parsed;
213
+ }
134
214
  /** The account this root owns — the content address of its genesis. */
135
215
  export async function accountForRoot(rootSecret) {
136
- const rootPublicKey = await derivePublicKey(rootSecret);
216
+ return accountForRootPublicKey(hex(await derivePublicKey(rootSecret)));
217
+ }
218
+ /**
219
+ * The same account id, from the root's **public** half.
220
+ *
221
+ * The form a root that cannot be exported needs. It is also the honest shape of
222
+ * the derivation: an account id is a function of the public key, and asking for
223
+ * the secret only ever meant "let me derive the public key first".
224
+ *
225
+ * @param rootPublicKey the root's ed25519 public key, 64 hex
226
+ */
227
+ export async function accountForRootPublicKey(rootPublicKey) {
137
228
  return hex(await domainHash(ACCOUNT_ID_DOMAIN, [
138
- concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey),
229
+ concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), fromHex(rootPublicKey, 'rootPublicKey', 32)),
139
230
  ]));
140
231
  }
@@ -1,2 +1,2 @@
1
- export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, accountProofBytes, } from './device-cert.js';
2
- export type { DeviceCertInput } from './device-cert.js';
1
+ export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, accountForRootPublicKey, accountProofBytes, parseDeviceCredential, verifyDeviceCredential, } from './device-cert.js';
2
+ export type { DeviceCertInput, DeviceCredential } from './device-cert.js';
@@ -1,2 +1,2 @@
1
1
  // Offline device certification — the account-root half of the keyholder story.
2
- export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, accountProofBytes, } from './device-cert.js';
2
+ export { signDeviceCert, mintDeviceId, deviceCertPayload, accountForRoot, accountForRootPublicKey, accountProofBytes, parseDeviceCredential, verifyDeviceCredential, } from './device-cert.js';
@@ -19,9 +19,22 @@ type SseEvent = SseEventData | GroupMembershipEventData | GroupMigrationEventDat
19
19
  type SseEventHandler = (event: SseEvent) => void;
20
20
  type SseConnectHandler = (sessionId: string) => void;
21
21
  type SseErrorHandler = (error: Error) => void;
22
+ /**
23
+ * How one request to the SSE endpoints is authenticated.
24
+ *
25
+ * A bearer token is one value reused across requests; a request proof commits to
26
+ * the method, the path and the body, so it must be produced per request. Both
27
+ * reduce to the same thing from this client's point of view — headers — so the
28
+ * stream takes the general form and {@link SseClient} adapts `getAuthToken` to it.
29
+ */
30
+ export type SseAuthorizer = (request: {
31
+ method: string;
32
+ path: string;
33
+ body?: string;
34
+ }) => Promise<Record<string, string>>;
22
35
  export declare class SseClient {
23
36
  private baseUrl;
24
- private getAuthToken;
37
+ private authorize;
25
38
  private reconnectDelayMs;
26
39
  private sessionId;
27
40
  private abortController;
@@ -33,7 +46,14 @@ export declare class SseClient {
33
46
  private fetchImpl;
34
47
  constructor(opts: {
35
48
  baseUrl: string;
36
- getAuthToken: () => Promise<string>;
49
+ /** A bearer token provider. The historical form, and still the default. */
50
+ getAuthToken?: () => Promise<string>;
51
+ /**
52
+ * Per-request authorization, for a credential that commits to the request —
53
+ * a device cert signing a request proof. Takes precedence over
54
+ * `getAuthToken`; one of the two is required.
55
+ */
56
+ authorize?: SseAuthorizer;
37
57
  reconnectDelayMs?: number;
38
58
  fetch?: typeof fetch;
39
59
  });
@@ -42,7 +42,16 @@ export class SseClient {
42
42
  this.closed = false;
43
43
  this.listeners = { connect: [], event: [], error: [] };
44
44
  this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
45
- this.getAuthToken = opts.getAuthToken;
45
+ if (opts.authorize) {
46
+ this.authorize = opts.authorize;
47
+ }
48
+ else if (opts.getAuthToken) {
49
+ const getAuthToken = opts.getAuthToken;
50
+ this.authorize = async () => ({ Authorization: `Bearer ${await getAuthToken()}` });
51
+ }
52
+ else {
53
+ throw new Error('SseClient needs either `getAuthToken` or `authorize`');
54
+ }
46
55
  this.reconnectDelayMs = opts.reconnectDelayMs ?? 3000;
47
56
  // globalThis.fetch must be called as a method on globalThis, not through a
48
57
  // variable, or browsers throw "Illegal invocation".
@@ -123,11 +132,10 @@ export class SseClient {
123
132
  this.closed = false;
124
133
  this.abortController = new AbortController();
125
134
  try {
126
- const token = await this.getAuthToken();
127
135
  const url = `${this.baseUrl}/sse`;
128
136
  const response = await this.fetchImpl(url, {
129
137
  headers: {
130
- 'Authorization': `Bearer ${token}`,
138
+ ...(await this.authorize({ method: 'GET', path: new URL(url).pathname })),
131
139
  'Accept': 'text/event-stream',
132
140
  },
133
141
  signal: this.abortController.signal,
@@ -297,23 +305,24 @@ export class SseClient {
297
305
  }
298
306
  async sendSubscription(method, ids) {
299
307
  try {
300
- const token = await this.getAuthToken();
301
308
  const params = {};
302
309
  if (ids.contextIds && ids.contextIds.length > 0)
303
310
  params.contextIds = ids.contextIds;
304
311
  if (ids.groupIds && ids.groupIds.length > 0)
305
312
  params.groupIds = ids.groupIds;
306
- const response = await this.fetchImpl(`${this.baseUrl}/sse/subscription`, {
313
+ const url = `${this.baseUrl}/sse/subscription`;
314
+ // Serialized once and both signed and sent, because a proof commits to the
315
+ // body: re-stringifying for the signature risks two byte sequences that
316
+ // differ in key order, which fails as a refused credential rather than as
317
+ // anything that names the real cause.
318
+ const body = JSON.stringify({ id: this.sessionId, method, params });
319
+ const response = await this.fetchImpl(url, {
307
320
  method: 'POST',
308
321
  headers: {
309
- 'Authorization': `Bearer ${token}`,
322
+ ...(await this.authorize({ method: 'POST', path: new URL(url).pathname, body })),
310
323
  'Content-Type': 'application/json',
311
324
  },
312
- body: JSON.stringify({
313
- id: this.sessionId,
314
- method,
315
- params,
316
- }),
325
+ body,
317
326
  });
318
327
  if (!response.ok) {
319
328
  // The subscribe path used to report only the status. On the one failure
@@ -4,6 +4,24 @@ export interface Transport {
4
4
  baseUrl: string;
5
5
  defaultHeaders?: Record<string, string>;
6
6
  getAuthToken?: () => Promise<string | undefined>;
7
+ /**
8
+ * Per-request authorization, for a credential that commits to the request.
9
+ *
10
+ * `getAuthToken` returns one value reused across requests; a request proof
11
+ * signs the method, the path and the body, so it can only be produced where
12
+ * those are known. This is called with exactly that, and whatever it returns
13
+ * is merged into the headers.
14
+ *
15
+ * It runs AFTER `getAuthToken`, so a transport holding both sends both and
16
+ * lets the node decide — which it does by answering a request carrying a token
17
+ * by that token, and consulting a proof only when there is none.
18
+ */
19
+ authorizeRequest?: (request: {
20
+ method: string;
21
+ /** The path the node will see, query excluded. */
22
+ path: string;
23
+ body?: string | Uint8Array;
24
+ }) => Promise<Record<string, string>>;
7
25
  /**
8
26
  * Sign each request instead of, or alongside, presenting a session.
9
27
  *
@@ -193,8 +193,28 @@ export class WebHttpClient {
193
193
  else {
194
194
  headersObj = headers;
195
195
  }
196
+ const method = init?.method || 'GET';
197
+ // A credential that commits to the request can only be produced here, where
198
+ // the method, the assembled path and the body are all known. The path comes
199
+ // from the URL rather than from `path`, because a base URL may carry a prefix
200
+ // (`…/admin-api`) that the node sees and a bare relative path would not sign.
201
+ if (this.transport.authorizeRequest) {
202
+ const body = init?.body;
203
+ if (body !== undefined && typeof body !== 'string' && !(body instanceof Uint8Array)) {
204
+ throw new Error('this transport authorizes each request against its body, so the body must be a string ' +
205
+ 'or Uint8Array — a stream cannot be signed, because the signature would have to read ' +
206
+ 'bytes the request then cannot send.');
207
+ }
208
+ Object.assign(headersObj, await this.transport.authorizeRequest({
209
+ method,
210
+ path: new URL(url).pathname,
211
+ // Narrowed by the guard above; `BodyInit` is wider than what can be
212
+ // signed.
213
+ body: body,
214
+ }));
215
+ }
196
216
  const requestInit = {
197
- method: init?.method || 'GET',
217
+ method,
198
218
  headers: headersObj,
199
219
  };
200
220
  // Check if body is a stream (ReadableStream) that can't be reused