@calimero-network/mero-js 19.3.0 → 19.5.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.
- package/dist/admin-api/admin-client.d.ts +5 -1
- package/dist/admin-api/admin-client.js +12 -0
- package/dist/admin-api/admin-types.d.ts +5 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +128 -0
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/index.mjs +128 -0
- package/dist/index.mjs.map +4 -4
- package/dist/login/index.d.ts +2 -0
- package/dist/login/index.js +1 -0
- package/dist/login/session.d.ts +108 -0
- package/dist/login/session.js +148 -0
- package/package.json +1 -1
package/dist/login/index.d.ts
CHANGED
package/dist/login/index.js
CHANGED
|
@@ -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
|
+
}
|