@calimero-network/mero-js 19.2.0 → 19.4.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.
@@ -0,0 +1,4 @@
1
+ export { signLoginStatement } from './login.js';
2
+ export type { Audience, LoginStatementInput } from './login.js';
3
+ export { login, generateSessionKey } from './session.js';
4
+ export type { LoginConfig, DelegatedSession, SessionKeyPair, } from './session.js';
@@ -0,0 +1,3 @@
1
+ // Login-statement signing — the device's half of a password-free session
2
+ export { signLoginStatement } from './login.js';
3
+ export { login, generateSessionKey } from './session.js';
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Sign the statement a device key presents to obtain a session on a node.
3
+ *
4
+ * This is the half of password-free login that has to run where the device key
5
+ * lives, and for the case delegated execution exists for that is a browser tab.
6
+ * Until this module, the only thing that could produce a login statement was
7
+ * `merod account login-statement` — which is to say, a client had to hold a node
8
+ * to ask a node for a session.
9
+ *
10
+ * **What this gets you, and what it does not.** A statement proves a device key
11
+ * asked for this session. It says nothing about which account that device
12
+ * belongs to: that is the `AccountProof` travelling beside it, which the node
13
+ * checks separately. Signing here and presenting no proof gets a 401 that looks
14
+ * exactly like a bad signature.
15
+ *
16
+ * **The session key is minted by the caller, not here.** The device key signs
17
+ * once, over a short-lived key the caller generates and keeps; that key does the
18
+ * talking for the rest of the session, which is what keeps the device key off
19
+ * the wire. Passing a key you do not hold the private half of produces a session
20
+ * you cannot speak on.
21
+ *
22
+ * **`node` must come from something you pinned.** Not from the challenge
23
+ * response, and not from any field the node itself chose — an attacker who can
24
+ * answer on the node's behalf would otherwise pick what your device signs about,
25
+ * which is the whole reason the field exists. Read it out of band from the node
26
+ * operator.
27
+ *
28
+ * **The byte contract is pinned in core**, at
29
+ * `crates/account/src/tests/login_wire_fixture.rs`, for the same reason the
30
+ * warrant one is: `AUTH_LOGIN_SIGN_DOMAIN` is `pub(crate)` there and the field
31
+ * order is implicit in a `derive`, so nothing on that side forces anyone to
32
+ * notice this file depends on both. This module's test asserts the same vectors.
33
+ */
34
+ /**
35
+ * The client surface a session is bound to.
36
+ *
37
+ * A session minted for one surface cannot be presented from another, so a single
38
+ * compromised surface does not yield sessions usable everywhere.
39
+ */
40
+ export type Audience = {
41
+ /**
42
+ * A browser origin, spelled exactly as the browser spells it
43
+ * (`https://host:port`, no trailing slash).
44
+ *
45
+ * Compared byte for byte by the node. Do not normalize it here: two
46
+ * spellings of one origin would mean a verifier that disagrees with the
47
+ * browser about which one a token was for. `location.origin` is already
48
+ * the spelling you want.
49
+ */
50
+ kind: 'webOrigin';
51
+ origin: string;
52
+ } | {
53
+ /** A signed native client, named by its code-signing identity. */
54
+ kind: 'codeSigningId';
55
+ id: string;
56
+ } | {
57
+ /**
58
+ * A command-line client, which has no origin and no signing identity.
59
+ *
60
+ * Carries no payload deliberately: a string here would be an audience that
61
+ * binds nothing while looking like it binds something.
62
+ */
63
+ kind: 'cli';
64
+ };
65
+ /** What a device is asking for. */
66
+ export interface LoginStatementInput {
67
+ /**
68
+ * The node this session is for, hex (32 bytes) — its identity public key, as
69
+ * you pinned it. See the module note: this must not come from the node.
70
+ */
71
+ node: string;
72
+ /** The client surface the session is bound to. */
73
+ audience: Audience;
74
+ /** The challenge this node issued, hex (32 bytes). Proves freshness. */
75
+ challenge: string;
76
+ /**
77
+ * The public half of the ephemeral key this session will speak with, hex (32
78
+ * bytes). Minted per session by the caller and thrown away with it.
79
+ */
80
+ sessionKey: string;
81
+ /** Unix seconds at signing. */
82
+ issuedAt: number | bigint;
83
+ /** Unix seconds after which the node must refuse it. */
84
+ expiresAt: number | bigint;
85
+ /**
86
+ * The device's ed25519 signing secret, hex (32 bytes).
87
+ *
88
+ * Never sent anywhere. It signs locally and only the signature travels, which
89
+ * is what lets a statement be minted by something holding no node.
90
+ */
91
+ deviceSecret: string;
92
+ }
93
+ /**
94
+ * Sign a login statement and return it hex-encoded, ready to present with an
95
+ * account proof.
96
+ *
97
+ * Returns the encoding rather than an object for the reason `signWarrant` does:
98
+ * the signature covers exactly these bytes, and a caller that rebuilt the fields
99
+ * from JSON would have a second spelling able to disagree with what was signed.
100
+ */
101
+ export declare function signLoginStatement(input: LoginStatementInput): Promise<string>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Sign the statement a device key presents to obtain a session on a node.
3
+ *
4
+ * This is the half of password-free login that has to run where the device key
5
+ * lives, and for the case delegated execution exists for that is a browser tab.
6
+ * Until this module, the only thing that could produce a login statement was
7
+ * `merod account login-statement` — which is to say, a client had to hold a node
8
+ * to ask a node for a session.
9
+ *
10
+ * **What this gets you, and what it does not.** A statement proves a device key
11
+ * asked for this session. It says nothing about which account that device
12
+ * belongs to: that is the `AccountProof` travelling beside it, which the node
13
+ * checks separately. Signing here and presenting no proof gets a 401 that looks
14
+ * exactly like a bad signature.
15
+ *
16
+ * **The session key is minted by the caller, not here.** The device key signs
17
+ * once, over a short-lived key the caller generates and keeps; that key does the
18
+ * talking for the rest of the session, which is what keeps the device key off
19
+ * the wire. Passing a key you do not hold the private half of produces a session
20
+ * you cannot speak on.
21
+ *
22
+ * **`node` must come from something you pinned.** Not from the challenge
23
+ * response, and not from any field the node itself chose — an attacker who can
24
+ * answer on the node's behalf would otherwise pick what your device signs about,
25
+ * which is the whole reason the field exists. Read it out of band from the node
26
+ * operator.
27
+ *
28
+ * **The byte contract is pinned in core**, at
29
+ * `crates/account/src/tests/login_wire_fixture.rs`, for the same reason the
30
+ * warrant one is: `AUTH_LOGIN_SIGN_DOMAIN` is `pub(crate)` there and the field
31
+ * order is implicit in a `derive`, so nothing on that side forces anyone to
32
+ * notice this file depends on both. This module's test asserts the same vectors.
33
+ */
34
+ import { concat, derivePublicKey, domainHash, fromHex, hex, importSigningKey, u32le, u64le, } from '../crypto/internal.js';
35
+ const SIGN_DOMAIN = new TextEncoder().encode('calimero.auth.login.v1');
36
+ /** The borsh enum tag for each variant, in core's declaration order. */
37
+ const AUDIENCE_TAG = {
38
+ webOrigin: 0,
39
+ codeSigningId: 1,
40
+ cli: 2,
41
+ };
42
+ /** The variant's payload as UTF-8 — empty for the payload-free `cli`. */
43
+ function audienceBody(audience) {
44
+ const encoder = new TextEncoder();
45
+ switch (audience.kind) {
46
+ case 'webOrigin':
47
+ return encoder.encode(audience.origin);
48
+ case 'codeSigningId':
49
+ return encoder.encode(audience.id);
50
+ case 'cli':
51
+ return new Uint8Array(0);
52
+ }
53
+ }
54
+ /**
55
+ * What the audience contributes to the **signing preimage**: tag then body, with
56
+ * no length between them.
57
+ *
58
+ * `domainHash` length-prefixes every part it is handed, so a length written here
59
+ * would be counted twice and the preimage would verify nowhere. This is the one
60
+ * field spelled differently in the two encodings — see {@link audienceWire}.
61
+ */
62
+ function audienceSigningBytes(audience) {
63
+ return concat(Uint8Array.from([AUDIENCE_TAG[audience.kind]]), audienceBody(audience));
64
+ }
65
+ /**
66
+ * What the audience contributes to the **wire encoding**: the borsh enum tag,
67
+ * then for the two payload-carrying variants a borsh `String` — its own `u32`
68
+ * little-endian length, then the UTF-8.
69
+ *
70
+ * `cli` is a bare tag. Writing a `u32` zero after it instead produces four extra
71
+ * bytes that a node reads as the start of the challenge, so the statement fails
72
+ * as a bad signature rather than as a malformed one.
73
+ */
74
+ function audienceWire(audience) {
75
+ const tag = Uint8Array.from([AUDIENCE_TAG[audience.kind]]);
76
+ if (audience.kind === 'cli') {
77
+ return tag;
78
+ }
79
+ const body = audienceBody(audience);
80
+ return concat(tag, u32le(body.length), body);
81
+ }
82
+ /**
83
+ * Sign a login statement and return it hex-encoded, ready to present with an
84
+ * account proof.
85
+ *
86
+ * Returns the encoding rather than an object for the reason `signWarrant` does:
87
+ * the signature covers exactly these bytes, and a caller that rebuilt the fields
88
+ * from JSON would have a second spelling able to disagree with what was signed.
89
+ */
90
+ export async function signLoginStatement(input) {
91
+ const node = fromHex(input.node, 'node', 32);
92
+ const challenge = fromHex(input.challenge, 'challenge', 32);
93
+ const sessionKey = fromHex(input.sessionKey, 'sessionKey', 32);
94
+ const key = await importSigningKey(input.deviceSecret);
95
+ const deviceKey = await derivePublicKey(input.deviceSecret);
96
+ const issuedAt = u64le(input.issuedAt);
97
+ const expiresAt = u64le(input.expiresAt);
98
+ const preimage = await domainHash(SIGN_DOMAIN, [
99
+ node,
100
+ audienceSigningBytes(input.audience),
101
+ challenge,
102
+ sessionKey,
103
+ deviceKey,
104
+ issuedAt,
105
+ expiresAt,
106
+ ]);
107
+ const signature = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, preimage));
108
+ return hex(concat(node, audienceWire(input.audience), challenge, sessionKey, deviceKey, issuedAt, expiresAt, signature));
109
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Obtain a session on a node using only a device key — the handshake around
3
+ * {@link signLoginStatement}.
4
+ *
5
+ * Signing a statement was never the whole job. A client also has to fetch a
6
+ * challenge, mint a session keypair, assemble a request body whose shape it
7
+ * cannot see from here, and read a token out of the reply. Until now every
8
+ * consumer wrote those two HTTP calls by hand, which is how the same three
9
+ * mistakes kept being available to make:
10
+ *
11
+ * 1. **Asking for permissions.** `POST /auth/token` takes an optional
12
+ * `permissions` list. Left unset, the node grants the account-proof
13
+ * provider's own scopes — `context:intent`, `context:query`,
14
+ * `context:subscribe` — which are the whole delegated surface and
15
+ * deliberately nothing above it. This module never sends the field.
16
+ * `authenticate()` in this same package hardcodes `['admin']` (#84); a
17
+ * device-key session would be refused for that and should never ask.
18
+ * 2. **Learning the node's key from the node.** {@link LoginConfig.node} is
19
+ * required and has no default, because a value read from the challenge
20
+ * response would let whoever answered choose what the device signs about.
21
+ * That is the whole attack the field exists to stop, so the awkwardness of
22
+ * having to supply it is the point rather than something to smooth away.
23
+ * 3. **Losing the refresh token.** A browser tab outlives a short-lived
24
+ * session, so both tokens are returned. Nothing here refreshes on a timer
25
+ * (#87, #88 track that); this only makes it possible without a second
26
+ * breaking change.
27
+ *
28
+ * **The session key never leaves.** It is minted here, the device key signs
29
+ * over it once, and the secret half is handed back to the caller. That is what
30
+ * keeps a device key off the wire for the life of a session: a leaked token
31
+ * cannot be escalated into use of the key that authorised it.
32
+ */
33
+ import { type Audience } from './login.js';
34
+ /** A freshly minted ephemeral keypair, hex-encoded. */
35
+ export interface SessionKeyPair {
36
+ /** The public half — what the statement is signed over. */
37
+ publicKey: string;
38
+ /** The private half. Held by the caller; never sent anywhere. */
39
+ secret: string;
40
+ }
41
+ /**
42
+ * Mint the ephemeral keypair a session speaks with.
43
+ *
44
+ * An ed25519 secret *is* 32 random bytes, so this needs no key generation
45
+ * ceremony — `getRandomValues` and the same derivation every other signer here
46
+ * uses. Exported because a caller that wants to persist a session across a page
47
+ * reload needs to mint the pair itself and keep the secret.
48
+ */
49
+ export declare function generateSessionKey(): Promise<SessionKeyPair>;
50
+ /** What a device needs in order to ask for a session. */
51
+ export interface LoginConfig {
52
+ /** The node's base URL, e.g. `https://node.example`. */
53
+ nodeUrl: string;
54
+ /**
55
+ * The node's device signing key, hex (32 bytes), **as this client pinned it**.
56
+ *
57
+ * Learn it out of band — from the node's operator, or a certificate you
58
+ * already trust. Never from the node you are about to log in to: see the
59
+ * module note.
60
+ */
61
+ node: string;
62
+ /** The device's ed25519 signing secret, hex (32 bytes). Never sent. */
63
+ deviceSecret: string;
64
+ /**
65
+ * The device's account proof, hex — the credential certifying that this
66
+ * device belongs to its account.
67
+ *
68
+ * Travels beside the statement and is what settles *which account* is asking.
69
+ * The statement alone proves only that a device key asked, so logging in
70
+ * without this yields a 401 that looks exactly like a bad signature.
71
+ */
72
+ accountProof: string;
73
+ /** The client surface this session is bound to. */
74
+ audience: Audience;
75
+ /**
76
+ * How this client names itself to the node. Defaults to the audience's own
77
+ * spelling, which is the honest answer: a session bound to an origin should
78
+ * say that origin.
79
+ */
80
+ clientName?: string;
81
+ /** Seconds the statement stays valid. Defaults to {@link DEFAULT_TTL_SECONDS}. */
82
+ ttlSeconds?: number;
83
+ /** Injectable for tests and non-browser runtimes. */
84
+ fetch?: typeof fetch;
85
+ /** Per-request timeout. Defaults to 10s. */
86
+ timeoutMs?: number;
87
+ }
88
+ /** A session minted without a password. */
89
+ export interface DelegatedSession {
90
+ /** Bearer token for the delegated surface. */
91
+ accessToken: string;
92
+ /** Presented to renew the access token. See #87/#88 — nothing here schedules it. */
93
+ refreshToken: string;
94
+ /** The session's public half, as the node knows it. */
95
+ sessionKey: string;
96
+ /** The session's private half. The caller keeps this; it was never sent. */
97
+ sessionSecret: string;
98
+ }
99
+ /**
100
+ * Log in with a device key and return the session.
101
+ *
102
+ * The challenge is fetched immediately before signing on purpose: it is
103
+ * single-use and short-lived, so a statement minted against a stale one is
104
+ * refused before any signature work is done.
105
+ *
106
+ * @throws {HTTPError} if the node refuses the challenge or the token request.
107
+ */
108
+ export declare function login(config: LoginConfig): Promise<DelegatedSession>;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Obtain a session on a node using only a device key — the handshake around
3
+ * {@link signLoginStatement}.
4
+ *
5
+ * Signing a statement was never the whole job. A client also has to fetch a
6
+ * challenge, mint a session keypair, assemble a request body whose shape it
7
+ * cannot see from here, and read a token out of the reply. Until now every
8
+ * consumer wrote those two HTTP calls by hand, which is how the same three
9
+ * mistakes kept being available to make:
10
+ *
11
+ * 1. **Asking for permissions.** `POST /auth/token` takes an optional
12
+ * `permissions` list. Left unset, the node grants the account-proof
13
+ * provider's own scopes — `context:intent`, `context:query`,
14
+ * `context:subscribe` — which are the whole delegated surface and
15
+ * deliberately nothing above it. This module never sends the field.
16
+ * `authenticate()` in this same package hardcodes `['admin']` (#84); a
17
+ * device-key session would be refused for that and should never ask.
18
+ * 2. **Learning the node's key from the node.** {@link LoginConfig.node} is
19
+ * required and has no default, because a value read from the challenge
20
+ * response would let whoever answered choose what the device signs about.
21
+ * That is the whole attack the field exists to stop, so the awkwardness of
22
+ * having to supply it is the point rather than something to smooth away.
23
+ * 3. **Losing the refresh token.** A browser tab outlives a short-lived
24
+ * session, so both tokens are returned. Nothing here refreshes on a timer
25
+ * (#87, #88 track that); this only makes it possible without a second
26
+ * breaking change.
27
+ *
28
+ * **The session key never leaves.** It is minted here, the device key signs
29
+ * over it once, and the secret half is handed back to the caller. That is what
30
+ * keeps a device key off the wire for the life of a session: a leaked token
31
+ * cannot be escalated into use of the key that authorised it.
32
+ */
33
+ import { HTTPError } from '../http-client/web-client.js';
34
+ import { derivePublicKey, hex } from '../crypto/internal.js';
35
+ import { signLoginStatement } from './login.js';
36
+ /** Seconds a statement stays valid unless told otherwise. */
37
+ const DEFAULT_TTL_SECONDS = 300;
38
+ /**
39
+ * Mint the ephemeral keypair a session speaks with.
40
+ *
41
+ * An ed25519 secret *is* 32 random bytes, so this needs no key generation
42
+ * ceremony — `getRandomValues` and the same derivation every other signer here
43
+ * uses. Exported because a caller that wants to persist a session across a page
44
+ * reload needs to mint the pair itself and keep the secret.
45
+ */
46
+ export async function generateSessionKey() {
47
+ const secret = hex(crypto.getRandomValues(new Uint8Array(32)));
48
+ return { publicKey: hex(await derivePublicKey(secret)), secret };
49
+ }
50
+ /** The audience's own spelling, used as the default client name. */
51
+ function audienceLabel(audience) {
52
+ switch (audience.kind) {
53
+ case 'webOrigin':
54
+ return audience.origin;
55
+ case 'codeSigningId':
56
+ return audience.id;
57
+ case 'cli':
58
+ return 'cli';
59
+ }
60
+ }
61
+ /**
62
+ * Log in with a device key and return the session.
63
+ *
64
+ * The challenge is fetched immediately before signing on purpose: it is
65
+ * single-use and short-lived, so a statement minted against a stale one is
66
+ * refused before any signature work is done.
67
+ *
68
+ * @throws {HTTPError} if the node refuses the challenge or the token request.
69
+ */
70
+ export async function login(config) {
71
+ const baseUrl = config.nodeUrl.replace(/\/+$/, '');
72
+ const doFetch = config.fetch ?? globalThis.fetch;
73
+ const timeoutMs = config.timeoutMs ?? 10000;
74
+ const request = async (path, init) => {
75
+ const controller = new AbortController();
76
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
77
+ let response;
78
+ try {
79
+ response = await doFetch(`${baseUrl}${path}`, {
80
+ ...init,
81
+ signal: controller.signal,
82
+ });
83
+ }
84
+ catch (err) {
85
+ throw new HTTPError(0, err instanceof Error ? err.name : 'NetworkError', `${baseUrl}${path}`, new Headers(), err instanceof Error ? err.message : String(err));
86
+ }
87
+ finally {
88
+ clearTimeout(timer);
89
+ }
90
+ const text = await response.text().catch(() => '');
91
+ if (!response.ok) {
92
+ throw new HTTPError(response.status, response.statusText, `${baseUrl}${path}`, response.headers, text);
93
+ }
94
+ return text ? JSON.parse(text) : {};
95
+ };
96
+ const challengeBody = (await request('/auth/challenge', {
97
+ method: 'GET',
98
+ headers: { Accept: 'application/json' },
99
+ }));
100
+ // The node has spelled this both bare and wrapped in `data`; accept either
101
+ // rather than couple a login to one envelope revision.
102
+ const challenge = challengeBody.challenge ?? challengeBody.data?.challenge;
103
+ if (!challenge) {
104
+ throw new Error('the node issued no challenge');
105
+ }
106
+ const session = await generateSessionKey();
107
+ const issuedAt = Math.floor(Date.now() / 1000);
108
+ const statement = await signLoginStatement({
109
+ node: config.node,
110
+ audience: config.audience,
111
+ challenge,
112
+ sessionKey: session.publicKey,
113
+ issuedAt,
114
+ expiresAt: issuedAt + (config.ttlSeconds ?? DEFAULT_TTL_SECONDS),
115
+ deviceSecret: config.deviceSecret,
116
+ });
117
+ // `timestamp` is required and the request refuses unknown fields, so a body
118
+ // missing it is rejected during deserialization — before the provider runs,
119
+ // and with an error that names the request shape rather than the login.
120
+ //
121
+ // `permissions` is omitted, not empty: see the module note.
122
+ const tokenBody = (await request('/auth/token', {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
125
+ body: JSON.stringify({
126
+ auth_method: 'account_proof',
127
+ public_key: session.publicKey,
128
+ client_name: config.clientName ?? audienceLabel(config.audience),
129
+ timestamp: issuedAt,
130
+ provider_data: {
131
+ challenge,
132
+ login_statement: statement,
133
+ account_proof: config.accountProof,
134
+ },
135
+ }),
136
+ }));
137
+ const accessToken = tokenBody.access_token ?? tokenBody.data?.access_token;
138
+ const refreshToken = tokenBody.refresh_token ?? tokenBody.data?.refresh_token;
139
+ if (!accessToken) {
140
+ throw new Error('the node minted no session');
141
+ }
142
+ return {
143
+ accessToken,
144
+ refreshToken: refreshToken ?? '',
145
+ sessionKey: session.publicKey,
146
+ sessionSecret: session.secret,
147
+ };
148
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calimero-network/mero-js",
3
- "version": "19.2.0",
3
+ "version": "19.4.0",
4
4
  "description": "Pure JavaScript SDK for Calimero",
5
5
  "type": "module",
6
6
  "exports": {