@calimero-network/mero-js 19.14.1 → 19.15.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/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +43 -2
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +35 -33
- package/dist/index.js +20 -19
- package/dist/index.mjs +43 -2
- package/dist/index.mjs.map +4 -4
- package/dist/request/index.d.ts +1 -0
- package/dist/request/index.js +1 -0
- package/dist/request/request-sig.d.ts +99 -0
- package/dist/request/request-sig.js +96 -0
- package/package.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { requestBodyHash, signRequest, type RequestSigInput, } from "./request-sig.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { requestBodyHash, signRequest, } from "./request-sig.js";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign one HTTP request, so a caller's identity travels with it.
|
|
3
|
+
*
|
|
4
|
+
* This is the bottom link of the chain a delegated client presents. A session
|
|
5
|
+
* has the node mint and remember something; this has the caller prove itself on
|
|
6
|
+
* every call, so the node holds no session state and the same path works
|
|
7
|
+
* whether or not the node runs a login provider at all.
|
|
8
|
+
*
|
|
9
|
+
* ```text
|
|
10
|
+
* account root ──signs──▶ DeviceCert cold, once, offline
|
|
11
|
+
* device key ──signs──▶ LoginStatement once per session, hours
|
|
12
|
+
* session key ──signs──▶ RequestSig per call ◀── this module
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* **The middle link is optional.** A CLI or a server-side job signs with its
|
|
16
|
+
* device key directly and presents two links; a browser keeping the device key
|
|
17
|
+
* behind a hardware or extension boundary mints a session key and presents
|
|
18
|
+
* three. The node's verifier handles both, so which you produce here is a
|
|
19
|
+
* question about where your key lives, not about what the node accepts.
|
|
20
|
+
*
|
|
21
|
+
* **What it binds.** Method, path and a hash of the body. A signature for
|
|
22
|
+
* `GET /a` is not a signature for `POST /a`, for `GET /b`, or for the same call
|
|
23
|
+
* with different arguments.
|
|
24
|
+
*
|
|
25
|
+
* **The path excludes the query string**, deliberately: a proxy may legitimately
|
|
26
|
+
* rewrite it — a token parameter most of all — and signing over bytes something
|
|
27
|
+
* else is entitled to change means failing for reasons the caller cannot see.
|
|
28
|
+
* Anything that must be bound belongs in the body.
|
|
29
|
+
*
|
|
30
|
+
* **The method is not normalized.** `HEAD` and `GET` are the same *permission*
|
|
31
|
+
* and the node's permission layer folds them, but they are different requests.
|
|
32
|
+
* Sign the method you are about to send.
|
|
33
|
+
*
|
|
34
|
+
* **Replay inside the window performs the identical request** and is bounded
|
|
35
|
+
* rather than prevented — keep `expiresAt` short. A warrant, which authorizes a
|
|
36
|
+
* change with lasting effect, carries a nonce ledger for this; a request
|
|
37
|
+
* signature does not.
|
|
38
|
+
*
|
|
39
|
+
* **The byte contract is pinned in core**, at
|
|
40
|
+
* `crates/account/src/tests/request_wire_fixture.rs`, for the same reason the
|
|
41
|
+
* warrant and login ones are: the domains are `pub(crate)` there and the field
|
|
42
|
+
* order is implicit in a `derive`, so nothing on that side forces anyone to
|
|
43
|
+
* notice this file depends on both. This module's test asserts the same vectors.
|
|
44
|
+
*/
|
|
45
|
+
import { fromHex } from "../crypto/internal.js";
|
|
46
|
+
/**
|
|
47
|
+
* Commit to a request body.
|
|
48
|
+
*
|
|
49
|
+
* Always call it, including for a body-less request: an empty body hashes to a
|
|
50
|
+
* value like any other, and there is no "absent body" encoding for a verifier
|
|
51
|
+
* to disagree with you about.
|
|
52
|
+
*/
|
|
53
|
+
export declare function requestBodyHash(body?: Uint8Array | string): Promise<Uint8Array>;
|
|
54
|
+
/** What a caller is about to send. */
|
|
55
|
+
export interface RequestSigInput {
|
|
56
|
+
/**
|
|
57
|
+
* The HTTP method, exactly as it will appear on the wire — `GET`, `POST`,
|
|
58
|
+
* `HEAD`. Not folded: see the module note.
|
|
59
|
+
*/
|
|
60
|
+
method: string;
|
|
61
|
+
/**
|
|
62
|
+
* The path, **without** the query string. `/admin-api/namespaces`, not
|
|
63
|
+
* `/admin-api/namespaces?token=…`.
|
|
64
|
+
*/
|
|
65
|
+
path: string;
|
|
66
|
+
/**
|
|
67
|
+
* The request body, or nothing. Pass exactly the bytes you will send: the
|
|
68
|
+
* signature commits to their hash, so a body re-serialized between signing
|
|
69
|
+
* and sending is a signature for a different request.
|
|
70
|
+
*/
|
|
71
|
+
body?: Uint8Array | string;
|
|
72
|
+
/** Unix seconds at signing. */
|
|
73
|
+
issuedAt: number | bigint;
|
|
74
|
+
/**
|
|
75
|
+
* Unix seconds after which the node must refuse it.
|
|
76
|
+
*
|
|
77
|
+
* Keep it short — minutes, not hours. It is what bounds replay, and nothing
|
|
78
|
+
* else does.
|
|
79
|
+
*/
|
|
80
|
+
expiresAt: number | bigint;
|
|
81
|
+
/**
|
|
82
|
+
* The ed25519 signing secret, hex (32 bytes): the session key on the
|
|
83
|
+
* three-link chain, the device key on the two-link one.
|
|
84
|
+
*
|
|
85
|
+
* Never sent anywhere. It signs locally and only the signature travels.
|
|
86
|
+
*/
|
|
87
|
+
signerSecret: string;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Sign one request and return it hex-encoded, ready to present.
|
|
91
|
+
*
|
|
92
|
+
* Returns the encoding rather than an object for the reason `signWarrant` and
|
|
93
|
+
* `signLoginStatement` do: the signature covers exactly these bytes, and a
|
|
94
|
+
* caller that rebuilt the fields from JSON would have a second spelling able to
|
|
95
|
+
* disagree with what was signed.
|
|
96
|
+
*/
|
|
97
|
+
export declare function signRequest(input: RequestSigInput): Promise<string>;
|
|
98
|
+
/** Re-exported so a caller can check a hex signer key before signing with it. */
|
|
99
|
+
export { fromHex };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sign one HTTP request, so a caller's identity travels with it.
|
|
3
|
+
*
|
|
4
|
+
* This is the bottom link of the chain a delegated client presents. A session
|
|
5
|
+
* has the node mint and remember something; this has the caller prove itself on
|
|
6
|
+
* every call, so the node holds no session state and the same path works
|
|
7
|
+
* whether or not the node runs a login provider at all.
|
|
8
|
+
*
|
|
9
|
+
* ```text
|
|
10
|
+
* account root ──signs──▶ DeviceCert cold, once, offline
|
|
11
|
+
* device key ──signs──▶ LoginStatement once per session, hours
|
|
12
|
+
* session key ──signs──▶ RequestSig per call ◀── this module
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* **The middle link is optional.** A CLI or a server-side job signs with its
|
|
16
|
+
* device key directly and presents two links; a browser keeping the device key
|
|
17
|
+
* behind a hardware or extension boundary mints a session key and presents
|
|
18
|
+
* three. The node's verifier handles both, so which you produce here is a
|
|
19
|
+
* question about where your key lives, not about what the node accepts.
|
|
20
|
+
*
|
|
21
|
+
* **What it binds.** Method, path and a hash of the body. A signature for
|
|
22
|
+
* `GET /a` is not a signature for `POST /a`, for `GET /b`, or for the same call
|
|
23
|
+
* with different arguments.
|
|
24
|
+
*
|
|
25
|
+
* **The path excludes the query string**, deliberately: a proxy may legitimately
|
|
26
|
+
* rewrite it — a token parameter most of all — and signing over bytes something
|
|
27
|
+
* else is entitled to change means failing for reasons the caller cannot see.
|
|
28
|
+
* Anything that must be bound belongs in the body.
|
|
29
|
+
*
|
|
30
|
+
* **The method is not normalized.** `HEAD` and `GET` are the same *permission*
|
|
31
|
+
* and the node's permission layer folds them, but they are different requests.
|
|
32
|
+
* Sign the method you are about to send.
|
|
33
|
+
*
|
|
34
|
+
* **Replay inside the window performs the identical request** and is bounded
|
|
35
|
+
* rather than prevented — keep `expiresAt` short. A warrant, which authorizes a
|
|
36
|
+
* change with lasting effect, carries a nonce ledger for this; a request
|
|
37
|
+
* signature does not.
|
|
38
|
+
*
|
|
39
|
+
* **The byte contract is pinned in core**, at
|
|
40
|
+
* `crates/account/src/tests/request_wire_fixture.rs`, for the same reason the
|
|
41
|
+
* warrant and login ones are: the domains are `pub(crate)` there and the field
|
|
42
|
+
* order is implicit in a `derive`, so nothing on that side forces anyone to
|
|
43
|
+
* notice this file depends on both. This module's test asserts the same vectors.
|
|
44
|
+
*/
|
|
45
|
+
import { concat, domainHash, fromHex, hex, importSigningKey, u32le, u64le, } from "../crypto/internal.js";
|
|
46
|
+
const SIGN_DOMAIN = new TextEncoder().encode("calimero.auth.request.v1");
|
|
47
|
+
/**
|
|
48
|
+
* Its own domain, distinct from the signing one.
|
|
49
|
+
*
|
|
50
|
+
* The two are different jobs on the same bytes: this produces the commitment,
|
|
51
|
+
* the other signs over it. Sharing a domain would make the commitment a
|
|
52
|
+
* truncated disclosure of bytes something signs.
|
|
53
|
+
*/
|
|
54
|
+
const BODY_DOMAIN = new TextEncoder().encode("calimero.auth.request.body.v1");
|
|
55
|
+
/** A borsh `String`: its own `u32` little-endian length, then the UTF-8. */
|
|
56
|
+
function borshString(value) {
|
|
57
|
+
const bytes = new TextEncoder().encode(value);
|
|
58
|
+
return concat(u32le(bytes.length), bytes);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Commit to a request body.
|
|
62
|
+
*
|
|
63
|
+
* Always call it, including for a body-less request: an empty body hashes to a
|
|
64
|
+
* value like any other, and there is no "absent body" encoding for a verifier
|
|
65
|
+
* to disagree with you about.
|
|
66
|
+
*/
|
|
67
|
+
export async function requestBodyHash(body = new Uint8Array(0)) {
|
|
68
|
+
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
69
|
+
return domainHash(BODY_DOMAIN, [bytes]);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Sign one request and return it hex-encoded, ready to present.
|
|
73
|
+
*
|
|
74
|
+
* Returns the encoding rather than an object for the reason `signWarrant` and
|
|
75
|
+
* `signLoginStatement` do: the signature covers exactly these bytes, and a
|
|
76
|
+
* caller that rebuilt the fields from JSON would have a second spelling able to
|
|
77
|
+
* disagree with what was signed.
|
|
78
|
+
*/
|
|
79
|
+
export async function signRequest(input) {
|
|
80
|
+
const key = await importSigningKey(input.signerSecret);
|
|
81
|
+
const bodyHash = await requestBodyHash(input.body);
|
|
82
|
+
const issuedAt = u64le(input.issuedAt);
|
|
83
|
+
const expiresAt = u64le(input.expiresAt);
|
|
84
|
+
const encoder = new TextEncoder();
|
|
85
|
+
const preimage = await domainHash(SIGN_DOMAIN, [
|
|
86
|
+
encoder.encode(input.method),
|
|
87
|
+
encoder.encode(input.path),
|
|
88
|
+
bodyHash,
|
|
89
|
+
issuedAt,
|
|
90
|
+
expiresAt,
|
|
91
|
+
]);
|
|
92
|
+
const signature = new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key, preimage));
|
|
93
|
+
return hex(concat(borshString(input.method), borshString(input.path), bodyHash, issuedAt, expiresAt, signature));
|
|
94
|
+
}
|
|
95
|
+
/** Re-exported so a caller can check a hex signer key before signing with it. */
|
|
96
|
+
export { fromHex };
|