@calimero-network/mero-js 19.1.0 → 19.3.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-types.d.ts +37 -3
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +67 -0
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.mjs +67 -0
- package/dist/index.mjs.map +4 -4
- package/dist/login/index.d.ts +2 -0
- package/dist/login/index.js +2 -0
- package/dist/login/login.d.ts +101 -0
- package/dist/login/login.js +109 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|