@calimero-network/mero-js 22.5.1 → 22.6.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 +143 -18
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/index.mjs +143 -18
- package/dist/index.mjs.map +4 -4
- package/dist/relay/relay-client.d.ts +18 -1
- package/dist/relay/relay-client.js +18 -3
- package/dist/signer/index.d.ts +2 -0
- package/dist/signer/index.js +3 -0
- package/dist/signer/signer.d.ts +92 -0
- package/dist/signer/signer.js +147 -0
- package/dist/warrant/creation-warrant.d.ts +17 -3
- package/dist/warrant/creation-warrant.js +6 -5
- package/dist/warrant/governance-warrant.d.ts +17 -3
- package/dist/warrant/governance-warrant.js +6 -5
- package/dist/warrant/warrant.d.ts +20 -1
- package/dist/warrant/warrant.js +19 -4
- package/package.json +1 -1
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* unspendable: the number is gone and the write never happened.
|
|
29
29
|
*/
|
|
30
30
|
import type { GovernanceOp } from '../warrant/governance-op.js';
|
|
31
|
+
import { type Signer } from '../signer/signer.js';
|
|
31
32
|
import type { NonceSource } from './nonce-source.js';
|
|
32
33
|
/** Where and as whom to write, plus the key that consents. */
|
|
33
34
|
export interface RelayClientConfig {
|
|
@@ -60,8 +61,18 @@ export interface RelayClientConfig {
|
|
|
60
61
|
*
|
|
61
62
|
* Never transmitted. It signs in this process and only the signature leaves,
|
|
62
63
|
* which is the whole reason a keyholder can author without a node.
|
|
64
|
+
*
|
|
65
|
+
* Mutually exclusive with {@link RelayClientConfig.signer}: exactly one is
|
|
66
|
+
* required. A browser should prefer the signer: a secret held as a string is
|
|
67
|
+
* readable by anything on the origin, and a non-extractable key is not.
|
|
68
|
+
*/
|
|
69
|
+
deviceSecret?: string;
|
|
70
|
+
/**
|
|
71
|
+
* The author device's signer: use instead of
|
|
72
|
+
* {@link RelayClientConfig.deviceSecret} when the key cannot be exported
|
|
73
|
+
* (a non-extractable WebCrypto key, a passkey, a hardware or remote signer).
|
|
63
74
|
*/
|
|
64
|
-
|
|
75
|
+
signer?: Signer;
|
|
65
76
|
/** Where nonces come from. See {@link NonceSource} — a reset replays. */
|
|
66
77
|
nonces: NonceSource;
|
|
67
78
|
/**
|
|
@@ -198,6 +209,12 @@ export declare class RelayClient {
|
|
|
198
209
|
private readonly fetchImpl;
|
|
199
210
|
private readonly timeoutMs;
|
|
200
211
|
constructor(config: RelayClientConfig);
|
|
212
|
+
/**
|
|
213
|
+
* The author device's signer, built from `deviceSecret` when that is what was
|
|
214
|
+
* configured. A {@link Signer} and never the secret, so no caller of a
|
|
215
|
+
* `RelayClient` gains the ability to read key material.
|
|
216
|
+
*/
|
|
217
|
+
authorSigner(): Promise<Signer>;
|
|
201
218
|
/**
|
|
202
219
|
* Ask the relay what it can do in `contextId`.
|
|
203
220
|
*
|
|
@@ -32,6 +32,7 @@ import { signCreationWarrant } from '../warrant/creation-warrant.js';
|
|
|
32
32
|
import { signGovernanceWarrant } from '../warrant/governance-warrant.js';
|
|
33
33
|
import { HTTPError } from '../http-client/web-client.js';
|
|
34
34
|
import { hex } from '../crypto/internal.js';
|
|
35
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
35
36
|
/** How long a freshly minted warrant stays presentable, in seconds. */
|
|
36
37
|
const DEFAULT_TTL_SECONDS = 300;
|
|
37
38
|
/**
|
|
@@ -71,6 +72,14 @@ export class RelayClient {
|
|
|
71
72
|
: (input, init) => globalThis.fetch(input, init);
|
|
72
73
|
this.timeoutMs = config.timeoutMs ?? 10000;
|
|
73
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* The author device's signer, built from `deviceSecret` when that is what was
|
|
77
|
+
* configured. A {@link Signer} and never the secret, so no caller of a
|
|
78
|
+
* `RelayClient` gains the ability to read key material.
|
|
79
|
+
*/
|
|
80
|
+
async authorSigner() {
|
|
81
|
+
return resolveSigner(this.config.deviceSecret, this.config.signer, 'deviceSecret');
|
|
82
|
+
}
|
|
74
83
|
/**
|
|
75
84
|
* Ask the relay what it can do in `contextId`.
|
|
76
85
|
*
|
|
@@ -94,6 +103,8 @@ export class RelayClient {
|
|
|
94
103
|
*/
|
|
95
104
|
async execute(contextId, method, argsJson = {}) {
|
|
96
105
|
const executor = this.executorAccount ?? (await this.describe(contextId)).executorAccount;
|
|
106
|
+
// Resolved before the nonce, so a misconfigured key burns none.
|
|
107
|
+
const signer = await this.authorSigner();
|
|
97
108
|
const nonce = await this.config.nonces.next();
|
|
98
109
|
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
99
110
|
const warrant = await signWarrant({
|
|
@@ -104,7 +115,7 @@ export class RelayClient {
|
|
|
104
115
|
argsJson,
|
|
105
116
|
nonce,
|
|
106
117
|
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
107
|
-
|
|
118
|
+
signer,
|
|
108
119
|
});
|
|
109
120
|
const body = await this.json('POST', `/admin-api/contexts/${encodeURIComponent(contextId)}/intents`, { method, argsJson, warrant, authorProof: this.config.authorProof });
|
|
110
121
|
return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
|
|
@@ -157,6 +168,8 @@ export class RelayClient {
|
|
|
157
168
|
}
|
|
158
169
|
const executor = this.checkedExecutor(described.executorAccount);
|
|
159
170
|
const initArgs = input.initArgs ?? {};
|
|
171
|
+
// Resolved before the nonce, so a misconfigured key burns none.
|
|
172
|
+
const signer = await this.authorSigner();
|
|
160
173
|
const nonce = await this.config.nonces.next();
|
|
161
174
|
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
162
175
|
const { warrant } = await signCreationWarrant({
|
|
@@ -170,7 +183,7 @@ export class RelayClient {
|
|
|
170
183
|
initArgs,
|
|
171
184
|
nonce,
|
|
172
185
|
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
173
|
-
|
|
186
|
+
signer,
|
|
174
187
|
});
|
|
175
188
|
const body = await this.json('POST', `/admin-api/groups/${encodeURIComponent(input.groupId)}/context-intents`, { warrant, authorProof: this.config.authorProof, initArgs });
|
|
176
189
|
return {
|
|
@@ -211,6 +224,8 @@ export class RelayClient {
|
|
|
211
224
|
'(checked before signing, no nonce was spent)', false, 403);
|
|
212
225
|
}
|
|
213
226
|
const executor = this.checkedExecutor(described.executorAccount);
|
|
227
|
+
// Resolved before the nonce, so a misconfigured key burns none.
|
|
228
|
+
const signer = await this.authorSigner();
|
|
214
229
|
const nonce = await this.config.nonces.next();
|
|
215
230
|
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
216
231
|
const warrant = await signGovernanceWarrant({
|
|
@@ -220,7 +235,7 @@ export class RelayClient {
|
|
|
220
235
|
executor,
|
|
221
236
|
nonce,
|
|
222
237
|
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
223
|
-
|
|
238
|
+
signer,
|
|
224
239
|
});
|
|
225
240
|
const body = await this.json('POST', `/admin-api/groups/${encodeURIComponent(input.groupId)}/governance-intents`, { warrant, authorProof: this.config.authorProof, op: hex(input.op.bytes) });
|
|
226
241
|
return { groupId: body.data.groupId };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who signs, separated from what is signed.
|
|
3
|
+
*
|
|
4
|
+
* Every signing entry point in this package used to take 32 hex bytes. That is
|
|
5
|
+
* fine for a CLI or a server holding a key file, and it is the one thing a
|
|
6
|
+
* browser must not do: a secret that exists as a string can be read by anything
|
|
7
|
+
* running on the origin, and a stolen account key is the account — permanently,
|
|
8
|
+
* because unlike a device key there is nothing above it to revoke it with.
|
|
9
|
+
*
|
|
10
|
+
* WebCrypto can hold an Ed25519 key that **signs but cannot be exported**
|
|
11
|
+
* (`generateKey(..., extractable: false, ...)`). Such a key has no hex
|
|
12
|
+
* representation to pass, so it could not be used with this package at all —
|
|
13
|
+
* which pushed consumers into reimplementing core's wire formats around their
|
|
14
|
+
* own key. That is the failure `crates/account/src/tests/sdk_credential_fixture.rs`
|
|
15
|
+
* exists to document: a second implementation of a byte contract agreed with
|
|
16
|
+
* itself for a full release while core rejected every credential it produced.
|
|
17
|
+
*
|
|
18
|
+
* So the interface is the key's *capability*, not its material:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const signer = await signerFromSecret(deviceSecret); // as before
|
|
22
|
+
* const signer = await signerFromCryptoKey(privateKey, pubKey); // unexportable
|
|
23
|
+
* await signWarrant({ ...terms, signer });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* `deviceSecret` and `rootSecret` keep working everywhere they did. They are
|
|
27
|
+
* now one way of producing a `Signer` rather than the only way, and callers
|
|
28
|
+
* that pass a secret get a signer built for them.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Something that can produce Ed25519 signatures and name the key they verify
|
|
32
|
+
* against.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately the smallest surface that works: a signature and the public key
|
|
35
|
+
* it belongs to. Anything larger would start describing *how* the key is held,
|
|
36
|
+
* which is the detail this exists to hide — a hex seed, a non-extractable
|
|
37
|
+
* `CryptoKey`, a hardware token behind an async prompt, and a remote signing
|
|
38
|
+
* service all satisfy it.
|
|
39
|
+
*/
|
|
40
|
+
export interface Signer {
|
|
41
|
+
/**
|
|
42
|
+
* The public half, 64 hex characters.
|
|
43
|
+
*
|
|
44
|
+
* Carried rather than derived on demand because a non-extractable private key
|
|
45
|
+
* cannot produce it: the public half has to be captured when the key is
|
|
46
|
+
* generated. A signer that could not name its key would force every caller to
|
|
47
|
+
* pass the public key alongside it, and the two could then disagree.
|
|
48
|
+
*/
|
|
49
|
+
readonly publicKey: string;
|
|
50
|
+
/**
|
|
51
|
+
* Sign `payload` — the exact bytes, with no hashing or framing added.
|
|
52
|
+
*
|
|
53
|
+
* Every caller in this package passes a 32-byte domain-separated digest it
|
|
54
|
+
* has already computed, and an implementation that hashed again would produce
|
|
55
|
+
* signatures that verify nowhere.
|
|
56
|
+
*/
|
|
57
|
+
sign(payload: Uint8Array): Promise<Uint8Array>;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A signer over a raw 32-byte Ed25519 seed, hex.
|
|
61
|
+
*
|
|
62
|
+
* The public half is derived from the seed rather than accepted alongside it,
|
|
63
|
+
* for the reason core does the same: a caller able to name a key it does not
|
|
64
|
+
* hold could produce a statement it cannot sign, and the field would stop
|
|
65
|
+
* meaning "who authorised this".
|
|
66
|
+
*
|
|
67
|
+
* @param secret the seed, 64 hex characters
|
|
68
|
+
* @param label what to call it in an error — `deviceSecret`, `rootSecret`
|
|
69
|
+
*/
|
|
70
|
+
export declare function signerFromSecret(secret: string, label?: string): Promise<Signer>;
|
|
71
|
+
/**
|
|
72
|
+
* A signer over a `CryptoKey` this process may use and cannot read.
|
|
73
|
+
*
|
|
74
|
+
* The point of the whole interface. Generate the key with
|
|
75
|
+
* `generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])`, keep the pair's
|
|
76
|
+
* private half (structured-clone it into IndexedDB if it must survive a reload),
|
|
77
|
+
* and pass it here with its public half.
|
|
78
|
+
*
|
|
79
|
+
* @param privateKey the signing key — extractable or not, it is never exported
|
|
80
|
+
* @param publicKey its public half: 64 hex, or the pair's `publicKey` (which
|
|
81
|
+
* WebCrypto leaves exportable even when the private half is not)
|
|
82
|
+
*/
|
|
83
|
+
export declare function signerFromCryptoKey(privateKey: CryptoKey, publicKey: string | CryptoKey): Promise<Signer>;
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the signer an input describes, accepting either form.
|
|
86
|
+
*
|
|
87
|
+
* Internal. Every signing entry point takes an optional secret *and* an
|
|
88
|
+
* optional signer, and exactly one must be present — passing both is refused
|
|
89
|
+
* rather than silently preferring one, because the two could name different
|
|
90
|
+
* keys and the caller would have no way to tell which signed.
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveSigner(secret: string | undefined, signer: Signer | undefined, label: string): Promise<Signer>;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who signs, separated from what is signed.
|
|
3
|
+
*
|
|
4
|
+
* Every signing entry point in this package used to take 32 hex bytes. That is
|
|
5
|
+
* fine for a CLI or a server holding a key file, and it is the one thing a
|
|
6
|
+
* browser must not do: a secret that exists as a string can be read by anything
|
|
7
|
+
* running on the origin, and a stolen account key is the account — permanently,
|
|
8
|
+
* because unlike a device key there is nothing above it to revoke it with.
|
|
9
|
+
*
|
|
10
|
+
* WebCrypto can hold an Ed25519 key that **signs but cannot be exported**
|
|
11
|
+
* (`generateKey(..., extractable: false, ...)`). Such a key has no hex
|
|
12
|
+
* representation to pass, so it could not be used with this package at all —
|
|
13
|
+
* which pushed consumers into reimplementing core's wire formats around their
|
|
14
|
+
* own key. That is the failure `crates/account/src/tests/sdk_credential_fixture.rs`
|
|
15
|
+
* exists to document: a second implementation of a byte contract agreed with
|
|
16
|
+
* itself for a full release while core rejected every credential it produced.
|
|
17
|
+
*
|
|
18
|
+
* So the interface is the key's *capability*, not its material:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* const signer = await signerFromSecret(deviceSecret); // as before
|
|
22
|
+
* const signer = await signerFromCryptoKey(privateKey, pubKey); // unexportable
|
|
23
|
+
* await signWarrant({ ...terms, signer });
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* `deviceSecret` and `rootSecret` keep working everywhere they did. They are
|
|
27
|
+
* now one way of producing a `Signer` rather than the only way, and callers
|
|
28
|
+
* that pass a secret get a signer built for them.
|
|
29
|
+
*/
|
|
30
|
+
import { concat, fromHex, hex } from '../crypto/internal.js';
|
|
31
|
+
/** Ed25519 PKCS#8 prefix, so a raw 32-byte seed can be imported by WebCrypto. */
|
|
32
|
+
const PKCS8_ED25519_PREFIX = new Uint8Array([
|
|
33
|
+
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04,
|
|
34
|
+
0x22, 0x04, 0x20,
|
|
35
|
+
]);
|
|
36
|
+
/** Turn WebCrypto's bare `NotSupportedError` into something actionable. */
|
|
37
|
+
function unsupported(cause) {
|
|
38
|
+
// The original is folded into the message rather than passed as `cause`:
|
|
39
|
+
// this package targets ES2020, where `Error` has no `cause` option.
|
|
40
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : '';
|
|
41
|
+
return new Error('this runtime has no WebCrypto Ed25519, so nothing can be signed here. ' +
|
|
42
|
+
'Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it' +
|
|
43
|
+
detail);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A signer over a raw 32-byte Ed25519 seed, hex.
|
|
47
|
+
*
|
|
48
|
+
* The public half is derived from the seed rather than accepted alongside it,
|
|
49
|
+
* for the reason core does the same: a caller able to name a key it does not
|
|
50
|
+
* hold could produce a statement it cannot sign, and the field would stop
|
|
51
|
+
* meaning "who authorised this".
|
|
52
|
+
*
|
|
53
|
+
* @param secret the seed, 64 hex characters
|
|
54
|
+
* @param label what to call it in an error — `deviceSecret`, `rootSecret`
|
|
55
|
+
*/
|
|
56
|
+
export async function signerFromSecret(secret, label = 'secret') {
|
|
57
|
+
const seed = fromHex(secret, label, 32);
|
|
58
|
+
const pkcs8 = concat(PKCS8_ED25519_PREFIX, seed);
|
|
59
|
+
let privateKey;
|
|
60
|
+
let publicKey;
|
|
61
|
+
try {
|
|
62
|
+
// Imported twice on purpose: `extractable: true` is needed to read the
|
|
63
|
+
// public half out of a JWK, and the signing key is then re-imported
|
|
64
|
+
// unexportable so a `Signer` built from a secret is no more powerful than
|
|
65
|
+
// one built from a `CryptoKey`.
|
|
66
|
+
const exportable = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
|
|
67
|
+
const jwk = (await crypto.subtle.exportKey('jwk', exportable));
|
|
68
|
+
publicKey = hex(Uint8Array.from(atob(jwk.x.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)));
|
|
69
|
+
privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, false, ['sign']);
|
|
70
|
+
}
|
|
71
|
+
catch (cause) {
|
|
72
|
+
throw unsupported(cause);
|
|
73
|
+
}
|
|
74
|
+
return fromKeyAndPublic(privateKey, publicKey);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* A signer over a `CryptoKey` this process may use and cannot read.
|
|
78
|
+
*
|
|
79
|
+
* The point of the whole interface. Generate the key with
|
|
80
|
+
* `generateKey({ name: 'Ed25519' }, false, ['sign', 'verify'])`, keep the pair's
|
|
81
|
+
* private half (structured-clone it into IndexedDB if it must survive a reload),
|
|
82
|
+
* and pass it here with its public half.
|
|
83
|
+
*
|
|
84
|
+
* @param privateKey the signing key — extractable or not, it is never exported
|
|
85
|
+
* @param publicKey its public half: 64 hex, or the pair's `publicKey` (which
|
|
86
|
+
* WebCrypto leaves exportable even when the private half is not)
|
|
87
|
+
*/
|
|
88
|
+
export async function signerFromCryptoKey(privateKey, publicKey) {
|
|
89
|
+
if (privateKey.type !== 'private') {
|
|
90
|
+
throw new Error(`signerFromCryptoKey needs the private half of the pair, got a ${privateKey.type} key`);
|
|
91
|
+
}
|
|
92
|
+
if (!privateKey.usages.includes('sign')) {
|
|
93
|
+
throw new Error("this CryptoKey cannot sign: generate it with usages ['sign', 'verify']");
|
|
94
|
+
}
|
|
95
|
+
let publicHex;
|
|
96
|
+
if (typeof publicKey === 'string') {
|
|
97
|
+
// Validated rather than trusted: a wrong public key produces signatures
|
|
98
|
+
// that verify nowhere, and the refusal arrives at a relay as a 403 —
|
|
99
|
+
// nowhere near this call.
|
|
100
|
+
fromHex(publicKey, 'publicKey', 32);
|
|
101
|
+
publicHex = publicKey.trim().toLowerCase();
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
try {
|
|
105
|
+
publicHex = hex(new Uint8Array(await crypto.subtle.exportKey('raw', publicKey)));
|
|
106
|
+
}
|
|
107
|
+
catch (cause) {
|
|
108
|
+
const detail = cause instanceof Error ? `: ${cause.message}` : '';
|
|
109
|
+
throw new Error('could not read the public half of this key pair; pass the public key ' +
|
|
110
|
+
'as 64 hex instead' +
|
|
111
|
+
detail);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return fromKeyAndPublic(privateKey, publicHex);
|
|
115
|
+
}
|
|
116
|
+
/** The shared tail of both constructors. */
|
|
117
|
+
function fromKeyAndPublic(privateKey, publicKey) {
|
|
118
|
+
return {
|
|
119
|
+
publicKey,
|
|
120
|
+
async sign(payload) {
|
|
121
|
+
try {
|
|
122
|
+
return new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, privateKey, payload));
|
|
123
|
+
}
|
|
124
|
+
catch (cause) {
|
|
125
|
+
throw unsupported(cause);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the signer an input describes, accepting either form.
|
|
132
|
+
*
|
|
133
|
+
* Internal. Every signing entry point takes an optional secret *and* an
|
|
134
|
+
* optional signer, and exactly one must be present — passing both is refused
|
|
135
|
+
* rather than silently preferring one, because the two could name different
|
|
136
|
+
* keys and the caller would have no way to tell which signed.
|
|
137
|
+
*/
|
|
138
|
+
export async function resolveSigner(secret, signer, label) {
|
|
139
|
+
if (signer && secret !== undefined) {
|
|
140
|
+
throw new Error(`pass either ${label} or signer, not both — they may name different keys`);
|
|
141
|
+
}
|
|
142
|
+
if (signer)
|
|
143
|
+
return signer;
|
|
144
|
+
if (secret !== undefined)
|
|
145
|
+
return signerFromSecret(secret, label);
|
|
146
|
+
throw new Error(`${label} or signer is required`);
|
|
147
|
+
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* `crates/account/src/tests/creation_wire_fixture.rs`, and this module's test
|
|
22
22
|
* asserts the same vectors. A drift would surface at a relay as a 403.
|
|
23
23
|
*/
|
|
24
|
+
import { type Signer } from '../signer/signer.js';
|
|
24
25
|
/** What a creation warrant authorises. */
|
|
25
26
|
export interface CreationWarrantInput {
|
|
26
27
|
/** The group the context is created in, hex (32 bytes). */
|
|
@@ -71,8 +72,21 @@ export interface CreationWarrantInput {
|
|
|
71
72
|
* relay supplied is one the relay chose.
|
|
72
73
|
*/
|
|
73
74
|
governanceFloor?: string[];
|
|
74
|
-
/**
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* The author device's ed25519 signing secret, hex (32 bytes). Never sent.
|
|
77
|
+
*
|
|
78
|
+
* Mutually exclusive with {@link CreationWarrantInput.signer}: exactly one is required.
|
|
79
|
+
* Prefer `signer` in a browser, since a secret held as a string is readable
|
|
80
|
+
* by anything on the origin.
|
|
81
|
+
*/
|
|
82
|
+
deviceSecret?: string;
|
|
83
|
+
/**
|
|
84
|
+
* The author device's signer, in place of {@link CreationWarrantInput.deviceSecret}: for a
|
|
85
|
+
* key that cannot be exported to hex (a non-extractable WebCrypto key, a
|
|
86
|
+
* passkey, a hardware or remote signer). Its `publicKey` becomes the
|
|
87
|
+
* warrant's `author_device_key`, and it signs the 32-byte preimage as is.
|
|
88
|
+
*/
|
|
89
|
+
signer?: Signer;
|
|
76
90
|
}
|
|
77
91
|
/** A signed creation warrant and the seed it commits to. */
|
|
78
92
|
export interface SignedCreationWarrant {
|
|
@@ -86,7 +100,7 @@ export interface CreationWarrantFields {
|
|
|
86
100
|
group: string;
|
|
87
101
|
seed: string;
|
|
88
102
|
authorAccount: string;
|
|
89
|
-
/** The author device key
|
|
103
|
+
/** The author device key: the signer's public key. */
|
|
90
104
|
deviceKey: string;
|
|
91
105
|
executor: string;
|
|
92
106
|
applicationId: string;
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* `crates/account/src/tests/creation_wire_fixture.rs`, and this module's test
|
|
22
22
|
* asserts the same vectors. A drift would surface at a relay as a 403.
|
|
23
23
|
*/
|
|
24
|
-
import { concat,
|
|
25
|
-
import {
|
|
24
|
+
import { concat, domainHash, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
|
|
25
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
26
|
+
import { checkedSignature, citedHeads } from './warrant.js';
|
|
26
27
|
const SIGN_DOMAIN = new TextEncoder().encode('calimero.context-creation-warrant.v1');
|
|
27
28
|
const INIT_DOMAIN = new TextEncoder().encode('calimero.context-creation.init.v1');
|
|
28
29
|
/** core's cap on `service_name` and `name`, in UTF-8 bytes. */
|
|
@@ -82,8 +83,8 @@ export async function signCreationWarrant(input) {
|
|
|
82
83
|
const name = label(input.name, 'name');
|
|
83
84
|
const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
|
|
84
85
|
const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
|
|
85
|
-
const
|
|
86
|
-
const deviceKey =
|
|
86
|
+
const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
|
|
87
|
+
const deviceKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
|
|
87
88
|
const initHash = await creationInitHash(input.initArgs);
|
|
88
89
|
const preimage = await creationWarrantPreimage({
|
|
89
90
|
group,
|
|
@@ -100,7 +101,7 @@ export async function signCreationWarrant(input) {
|
|
|
100
101
|
nonce: input.nonce,
|
|
101
102
|
notAfter: input.notAfter,
|
|
102
103
|
});
|
|
103
|
-
const signature =
|
|
104
|
+
const signature = checkedSignature(await signer.sign(preimage));
|
|
104
105
|
const warrant = hex(concat(group, seed, authorAccount, deviceKey, executor, applicationId, borshOption(serviceName), borshOption(name), initHash, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, u64le(input.nonce), u64le(input.notAfter), signature));
|
|
105
106
|
return { warrant, seed: hex(seed) };
|
|
106
107
|
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
* `crates/account/src/tests/governance_wire_fixture.rs`, and this module's test
|
|
27
27
|
* asserts the same vectors. A drift would surface at a relay as a 400.
|
|
28
28
|
*/
|
|
29
|
+
import { type Signer } from '../signer/signer.js';
|
|
29
30
|
import type { GovernanceOp, GovernanceOpKind } from './governance-op.js';
|
|
30
31
|
/** What a governance warrant authorises. */
|
|
31
32
|
export interface GovernanceWarrantInput {
|
|
@@ -51,15 +52,28 @@ export interface GovernanceWarrantInput {
|
|
|
51
52
|
accountHeads?: string[];
|
|
52
53
|
/** Governance heads the author's view descended from, each hex (32 bytes). At most 64. */
|
|
53
54
|
governanceFloor?: string[];
|
|
54
|
-
/**
|
|
55
|
-
|
|
55
|
+
/**
|
|
56
|
+
* The author device's ed25519 signing secret, hex (32 bytes). Never sent.
|
|
57
|
+
*
|
|
58
|
+
* Mutually exclusive with {@link GovernanceWarrantInput.signer}: exactly one is required.
|
|
59
|
+
* Prefer `signer` in a browser, since a secret held as a string is readable
|
|
60
|
+
* by anything on the origin.
|
|
61
|
+
*/
|
|
62
|
+
deviceSecret?: string;
|
|
63
|
+
/**
|
|
64
|
+
* The author device's signer, in place of {@link GovernanceWarrantInput.deviceSecret}: for a
|
|
65
|
+
* key that cannot be exported to hex (a non-extractable WebCrypto key, a
|
|
66
|
+
* passkey, a hardware or remote signer). Its `publicKey` becomes the
|
|
67
|
+
* warrant's `author_device_key`, and it signs the 32-byte preimage as is.
|
|
68
|
+
*/
|
|
69
|
+
signer?: Signer;
|
|
56
70
|
}
|
|
57
71
|
/** A governance warrant's fields, decoded from the wire encoding. */
|
|
58
72
|
export interface GovernanceWarrantFields {
|
|
59
73
|
scope: string;
|
|
60
74
|
kind: GovernanceOpKind;
|
|
61
75
|
authorAccount: string;
|
|
62
|
-
/** The author device key
|
|
76
|
+
/** The author device key: the signer's public key. */
|
|
63
77
|
deviceKey: string;
|
|
64
78
|
executor: string;
|
|
65
79
|
opHash: string;
|
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
* `crates/account/src/tests/governance_wire_fixture.rs`, and this module's test
|
|
27
27
|
* asserts the same vectors. A drift would surface at a relay as a 400.
|
|
28
28
|
*/
|
|
29
|
-
import { concat,
|
|
30
|
-
import {
|
|
29
|
+
import { concat, domainHash, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
|
|
30
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
31
|
+
import { checkedSignature, citedHeads } from './warrant.js';
|
|
31
32
|
const SIGN_DOMAIN = new TextEncoder().encode('calimero.governance-warrant.v1');
|
|
32
33
|
const OP_DOMAIN = new TextEncoder().encode('calimero.governance-warrant.op.v1');
|
|
33
34
|
/** core's `GovernanceOpKind` discriminants. */
|
|
@@ -72,8 +73,8 @@ export async function signGovernanceWarrant(input) {
|
|
|
72
73
|
const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
|
|
73
74
|
const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
|
|
74
75
|
const kind = kindByte(input.op.kind);
|
|
75
|
-
const
|
|
76
|
-
const deviceKey =
|
|
76
|
+
const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
|
|
77
|
+
const deviceKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
|
|
77
78
|
const opHash = await governanceOpHash(input.op);
|
|
78
79
|
const preimage = await governanceWarrantPreimage({
|
|
79
80
|
scope,
|
|
@@ -87,7 +88,7 @@ export async function signGovernanceWarrant(input) {
|
|
|
87
88
|
nonce: input.nonce,
|
|
88
89
|
notAfter: input.notAfter,
|
|
89
90
|
});
|
|
90
|
-
const signature =
|
|
91
|
+
const signature = checkedSignature(await signer.sign(preimage));
|
|
91
92
|
return hex(concat(scope, new Uint8Array([kind]), authorAccount, deviceKey, executor, opHash, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, u64le(input.nonce), u64le(input.notAfter), signature));
|
|
92
93
|
}
|
|
93
94
|
/**
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* this producing well-formed warrants whose signatures verify nowhere, surfacing
|
|
28
28
|
* at a relay as a 403: an authorization refusal, nowhere near the cause.
|
|
29
29
|
*/
|
|
30
|
+
import { type Signer } from '../signer/signer.js';
|
|
30
31
|
/** Ed25519 PKCS#8 prefix, so a raw 32-byte seed can be imported by WebCrypto. */
|
|
31
32
|
/** What a warrant authorises. */
|
|
32
33
|
export interface WarrantInput {
|
|
@@ -103,8 +104,18 @@ export interface WarrantInput {
|
|
|
103
104
|
*
|
|
104
105
|
* Never sent anywhere. It signs locally and only the signature travels, which
|
|
105
106
|
* is the whole reason a warrant can be minted by something holding no node.
|
|
107
|
+
*
|
|
108
|
+
* Mutually exclusive with {@link WarrantInput.signer}: exactly one is
|
|
109
|
+
* required. Prefer `signer` in a browser, since a secret held as a string is
|
|
110
|
+
* readable by anything on the origin.
|
|
111
|
+
*/
|
|
112
|
+
deviceSecret?: string;
|
|
113
|
+
/**
|
|
114
|
+
* The author device's signer, in place of {@link WarrantInput.deviceSecret}:
|
|
115
|
+
* for a key that cannot be exported to hex (a non-extractable WebCrypto key,
|
|
116
|
+
* a passkey, a hardware or remote signer).
|
|
106
117
|
*/
|
|
107
|
-
|
|
118
|
+
signer?: Signer;
|
|
108
119
|
}
|
|
109
120
|
/**
|
|
110
121
|
* The commitment a warrant carries in place of the intent itself.
|
|
@@ -125,6 +136,14 @@ export declare function signWarrant(input: WarrantInput): Promise<string>;
|
|
|
125
136
|
* Decode a cited-head list, refusing one longer than a node will accept.
|
|
126
137
|
* Shared with `creation-warrant.ts`; internal, not exported from the package root.
|
|
127
138
|
*/
|
|
139
|
+
/**
|
|
140
|
+
* A signer's output, refused unless it is a 64-byte Ed25519 signature.
|
|
141
|
+
*
|
|
142
|
+
* The signature is spliced into the encoding at a fixed width, so a signer that
|
|
143
|
+
* returned anything else would yield a warrant that parses as garbage at a
|
|
144
|
+
* relay, far from the signer at fault. Internal.
|
|
145
|
+
*/
|
|
146
|
+
export declare function checkedSignature(signature: Uint8Array): Uint8Array;
|
|
128
147
|
export declare function citedHeads(heads: string[] | undefined, label: string): Uint8Array[];
|
|
129
148
|
/** A warrant's fields, as hex, decoded from the wire encoding. */
|
|
130
149
|
export interface WarrantFields {
|
package/dist/warrant/warrant.js
CHANGED
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* this producing well-formed warrants whose signatures verify nowhere, surfacing
|
|
28
28
|
* at a relay as a 403: an authorization refusal, nowhere near the cause.
|
|
29
29
|
*/
|
|
30
|
-
import { concat,
|
|
30
|
+
import { concat, domainHash, fromHex, hex, u32le, u64le } from '../crypto/internal.js';
|
|
31
|
+
import { resolveSigner } from '../signer/signer.js';
|
|
31
32
|
const SIGN_DOMAIN = new TextEncoder().encode('calimero.warrant.v2');
|
|
32
33
|
const INTENT_DOMAIN = new TextEncoder().encode('calimero.warrant.intent.v1');
|
|
33
34
|
/**
|
|
@@ -61,8 +62,8 @@ export async function signWarrant(input) {
|
|
|
61
62
|
const appVersion = fromHex(input.appVersion ?? UNPINNED_APP, 'appVersion', 32);
|
|
62
63
|
const accountHeads = citedHeads(input.accountHeads, 'accountHeads');
|
|
63
64
|
const governanceFloor = citedHeads(input.governanceFloor, 'governanceFloor');
|
|
64
|
-
const
|
|
65
|
-
const publicKey =
|
|
65
|
+
const signer = await resolveSigner(input.deviceSecret, input.signer, 'deviceSecret');
|
|
66
|
+
const publicKey = fromHex(signer.publicKey, 'signer.publicKey', 32);
|
|
66
67
|
const method = new TextEncoder().encode(input.method);
|
|
67
68
|
const commitment = await intentHash(input.method, input.argsJson);
|
|
68
69
|
const nonce = u64le(input.nonce);
|
|
@@ -87,7 +88,7 @@ export async function signWarrant(input) {
|
|
|
87
88
|
nonce,
|
|
88
89
|
notAfter,
|
|
89
90
|
]);
|
|
90
|
-
const signature =
|
|
91
|
+
const signature = checkedSignature(await signer.sign(preimage));
|
|
91
92
|
// Borsh: a `String` is a u32 length then its UTF-8 bytes; a `Vec<[u8; 32]>` is
|
|
92
93
|
// a u32 count then the elements. u32 HERE; u64 in the preimage above.
|
|
93
94
|
return hex(concat(context, authorAccount, publicKey, executor, appVersion, u32le(method.length), method, commitment, u32le(accountHeads.length), ...accountHeads, u32le(governanceFloor.length), ...governanceFloor, nonce, notAfter, signature));
|
|
@@ -96,6 +97,20 @@ export async function signWarrant(input) {
|
|
|
96
97
|
* Decode a cited-head list, refusing one longer than a node will accept.
|
|
97
98
|
* Shared with `creation-warrant.ts`; internal, not exported from the package root.
|
|
98
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* A signer's output, refused unless it is a 64-byte Ed25519 signature.
|
|
102
|
+
*
|
|
103
|
+
* The signature is spliced into the encoding at a fixed width, so a signer that
|
|
104
|
+
* returned anything else would yield a warrant that parses as garbage at a
|
|
105
|
+
* relay, far from the signer at fault. Internal.
|
|
106
|
+
*/
|
|
107
|
+
export function checkedSignature(signature) {
|
|
108
|
+
if (!(signature instanceof Uint8Array) || signature.length !== 64) {
|
|
109
|
+
const got = signature instanceof Uint8Array ? `${signature.length} bytes` : typeof signature;
|
|
110
|
+
throw new Error(`signer returned ${got}, expected a 64-byte Ed25519 signature`);
|
|
111
|
+
}
|
|
112
|
+
return signature;
|
|
113
|
+
}
|
|
99
114
|
export function citedHeads(heads, label) {
|
|
100
115
|
const list = heads ?? [];
|
|
101
116
|
if (list.length > MAX_CITED_HEADS) {
|