@openzeppelin/miden-multisig-client 0.14.5 → 0.14.6
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/README.md +32 -0
- package/dist/client.d.ts +21 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +20 -0
- package/dist/client.js.map +1 -1
- package/dist/client.test.js +77 -0
- package/dist/client.test.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/lookupAuth.d.ts +29 -0
- package/dist/lookupAuth.d.ts.map +1 -0
- package/dist/lookupAuth.js +85 -0
- package/dist/lookupAuth.js.map +1 -0
- package/dist/lookupAuth.test.d.ts +2 -0
- package/dist/lookupAuth.test.d.ts.map +1 -0
- package/dist/lookupAuth.test.js +137 -0
- package/dist/lookupAuth.test.js.map +1 -0
- package/dist/signers/ecdsa.d.ts +5 -0
- package/dist/signers/ecdsa.d.ts.map +1 -1
- package/dist/signers/ecdsa.js +9 -0
- package/dist/signers/ecdsa.js.map +1 -1
- package/dist/signers/falcon.d.ts +5 -0
- package/dist/signers/falcon.d.ts.map +1 -1
- package/dist/signers/falcon.js +9 -0
- package/dist/signers/falcon.js.map +1 -1
- package/dist/signers/miden-wallet.d.ts +5 -0
- package/dist/signers/miden-wallet.d.ts.map +1 -1
- package/dist/signers/miden-wallet.js +12 -0
- package/dist/signers/miden-wallet.js.map +1 -1
- package/dist/signers/miden-wallet.test.js +36 -0
- package/dist/signers/miden-wallet.test.js.map +1 -1
- package/dist/signers/para.d.ts +5 -0
- package/dist/signers/para.d.ts.map +1 -1
- package/dist/signers/para.js +9 -0
- package/dist/signers/para.js.map +1 -1
- package/dist/signers/para.test.js +19 -0
- package/dist/signers/para.test.js.map +1 -1
- package/package.json +3 -3
- package/src/client.test.ts +97 -0
- package/src/client.ts +36 -0
- package/src/index.ts +6 -1
- package/src/lookupAuth.test.ts +184 -0
- package/src/lookupAuth.ts +93 -0
- package/src/signers/ecdsa.ts +10 -0
- package/src/signers/falcon.ts +10 -0
- package/src/signers/miden-wallet.test.ts +48 -0
- package/src/signers/miden-wallet.ts +13 -0
- package/src/signers/para.test.ts +26 -0
- package/src/signers/para.ts +10 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// The Miden SDK is wasm-bound and is mocked across this test suite (matching
|
|
4
|
+
// the pattern in transaction.test.ts, multisig.test.ts, etc.). We cannot run
|
|
5
|
+
// real RPO256 here, but we CAN verify the lookup-digest function builds the
|
|
6
|
+
// FeltArray sequence in the right order and chunking convention. Cross-language
|
|
7
|
+
// parity against `crates/shared/tests/fixtures/lookup_auth_vectors.json` is
|
|
8
|
+
// intentionally a separate runner — see `runRealSdkParity` below for the gated
|
|
9
|
+
// hook.
|
|
10
|
+
|
|
11
|
+
interface FakeFelt {
|
|
12
|
+
__felt: true;
|
|
13
|
+
value: bigint;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FakeWord {
|
|
17
|
+
__word: true;
|
|
18
|
+
felts: FakeFelt[];
|
|
19
|
+
toFelts: () => FakeFelt[];
|
|
20
|
+
toHex: () => string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hashCalls: { input: FakeFelt[] }[] = [];
|
|
24
|
+
|
|
25
|
+
vi.mock('@miden-sdk/miden-sdk', () => {
|
|
26
|
+
const Felt = vi.fn().mockImplementation((value: bigint) => ({
|
|
27
|
+
__felt: true,
|
|
28
|
+
value,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const FeltArray = vi.fn().mockImplementation((felts: FakeFelt[]) => felts);
|
|
32
|
+
|
|
33
|
+
const makeWord = (felts: FakeFelt[], hex = '0xMOCKED'): FakeWord => ({
|
|
34
|
+
__word: true,
|
|
35
|
+
felts,
|
|
36
|
+
toFelts: () => felts,
|
|
37
|
+
toHex: () => hex,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const Word = {
|
|
41
|
+
fromHex: vi.fn().mockImplementation((hex: string) => {
|
|
42
|
+
// Deterministic four-felt decomposition: split hex into 4 8-byte pieces.
|
|
43
|
+
// The values are not real Word semantics, but they let us assert the
|
|
44
|
+
// implementation passes them through in order.
|
|
45
|
+
const stripped = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
46
|
+
const padded = stripped.padStart(64, '0');
|
|
47
|
+
const felts: FakeFelt[] = [];
|
|
48
|
+
for (let i = 0; i < 4; i += 1) {
|
|
49
|
+
const piece = padded.slice(i * 16, (i + 1) * 16);
|
|
50
|
+
felts.push({ __felt: true, value: BigInt('0x' + piece) });
|
|
51
|
+
}
|
|
52
|
+
return makeWord(felts, hex);
|
|
53
|
+
}),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const Rpo256 = {
|
|
57
|
+
hashElements: vi.fn().mockImplementation((felts: FakeFelt[]) => {
|
|
58
|
+
hashCalls.push({ input: [...felts] });
|
|
59
|
+
// Synthesize a fake digest: 4 felts derived from the input length and
|
|
60
|
+
// first felt's value, so each call's output is distinguishable.
|
|
61
|
+
const seed = felts.length === 0 ? 0n : felts[0].value;
|
|
62
|
+
return makeWord(
|
|
63
|
+
[
|
|
64
|
+
{ __felt: true, value: seed ^ BigInt(felts.length) },
|
|
65
|
+
{ __felt: true, value: seed + 1n },
|
|
66
|
+
{ __felt: true, value: seed + 2n },
|
|
67
|
+
{ __felt: true, value: seed + 3n },
|
|
68
|
+
],
|
|
69
|
+
'0xfakehash',
|
|
70
|
+
);
|
|
71
|
+
}),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return { Felt, FeltArray, Rpo256, Word };
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const DOMAIN_TAG_BYTES = new TextEncoder().encode('guardian.lookup.v1');
|
|
78
|
+
|
|
79
|
+
function bytesToExpectedFelts(bytes: Uint8Array): bigint[] {
|
|
80
|
+
const out: bigint[] = [];
|
|
81
|
+
for (let offset = 0; offset < bytes.length; offset += 8) {
|
|
82
|
+
let value = 0n;
|
|
83
|
+
for (let i = 0; i < 8; i += 1) {
|
|
84
|
+
const byte = offset + i < bytes.length ? bytes[offset + i] : 0;
|
|
85
|
+
value |= BigInt(byte) << BigInt(8 * i);
|
|
86
|
+
}
|
|
87
|
+
out.push(value);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let lookupAuthDigest: typeof import('./lookupAuth.js').lookupAuthDigest;
|
|
93
|
+
let _resetLookupDomainTagCacheForTesting: typeof import('./lookupAuth.js')._resetLookupDomainTagCacheForTesting;
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
hashCalls.length = 0;
|
|
97
|
+
// Re-import to pick up a fresh cached domain tag per test, in addition to
|
|
98
|
+
// the explicit reset, so test ordering does not change behavior.
|
|
99
|
+
const mod = await import('./lookupAuth.js');
|
|
100
|
+
lookupAuthDigest = mod.lookupAuthDigest;
|
|
101
|
+
_resetLookupDomainTagCacheForTesting = mod._resetLookupDomainTagCacheForTesting;
|
|
102
|
+
_resetLookupDomainTagCacheForTesting();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('lookupAuthDigest', () => {
|
|
106
|
+
it('hashes the domain-tag bytes via the same 8-byte LE chunking as Rust', () => {
|
|
107
|
+
const keyCommitmentHex = '0x' + 'aa'.repeat(32);
|
|
108
|
+
lookupAuthDigest(1_700_000_000_000, keyCommitmentHex);
|
|
109
|
+
|
|
110
|
+
// First Rpo256.hashElements call must be the domain-tag computation
|
|
111
|
+
// (3 felts for "guardian.lookup.v1" — 18 bytes → ceil(18/8) = 3 chunks).
|
|
112
|
+
expect(hashCalls.length).toBeGreaterThanOrEqual(2);
|
|
113
|
+
const tagCall = hashCalls[0];
|
|
114
|
+
const expected = bytesToExpectedFelts(DOMAIN_TAG_BYTES);
|
|
115
|
+
expect(tagCall.input).toHaveLength(expected.length);
|
|
116
|
+
expect(tagCall.input.length).toBe(3);
|
|
117
|
+
for (let i = 0; i < expected.length; i += 1) {
|
|
118
|
+
expect(tagCall.input[i].value).toBe(expected[i]);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('caches the domain-tag computation across calls', () => {
|
|
123
|
+
const keyCommitmentHex = '0x' + 'bb'.repeat(32);
|
|
124
|
+
lookupAuthDigest(1_700_000_000_000, keyCommitmentHex);
|
|
125
|
+
const callsAfterFirst = hashCalls.length;
|
|
126
|
+
lookupAuthDigest(1_700_000_000_001, keyCommitmentHex);
|
|
127
|
+
const callsAfterSecond = hashCalls.length;
|
|
128
|
+
|
|
129
|
+
// Second invocation should perform exactly one Rpo256.hashElements call
|
|
130
|
+
// (the message hash); the domain-tag hash is cached.
|
|
131
|
+
expect(callsAfterSecond - callsAfterFirst).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('passes 9 felts in [tag, timestamp, key_commitment] order to the message hash', () => {
|
|
135
|
+
const keyCommitmentHex = '0x' + '01'.repeat(32);
|
|
136
|
+
const timestamp = 1_700_000_000_000n;
|
|
137
|
+
lookupAuthDigest(timestamp, keyCommitmentHex);
|
|
138
|
+
|
|
139
|
+
const messageCall = hashCalls[hashCalls.length - 1];
|
|
140
|
+
expect(messageCall.input).toHaveLength(9);
|
|
141
|
+
|
|
142
|
+
// Felt 4 is the timestamp, reinterpreted as u64 (no change for positive).
|
|
143
|
+
expect(messageCall.input[4].value).toBe(timestamp);
|
|
144
|
+
|
|
145
|
+
// Felts 5..8 are the key-commitment felts, derived deterministically by
|
|
146
|
+
// the mocked Word.fromHex from the hex representation.
|
|
147
|
+
const expectedKcWord = '01'.repeat(32);
|
|
148
|
+
for (let i = 0; i < 4; i += 1) {
|
|
149
|
+
const piece = expectedKcWord.slice(i * 16, (i + 1) * 16);
|
|
150
|
+
expect(messageCall.input[5 + i].value).toBe(BigInt('0x' + piece));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('reinterprets a negative timestamp via two-complement u64 cast', () => {
|
|
155
|
+
const keyCommitmentHex = '0x' + 'cc'.repeat(32);
|
|
156
|
+
lookupAuthDigest(-1, keyCommitmentHex);
|
|
157
|
+
|
|
158
|
+
const messageCall = hashCalls[hashCalls.length - 1];
|
|
159
|
+
// -1 cast to u64 is 0xFFFFFFFFFFFFFFFF.
|
|
160
|
+
expect(messageCall.input[4].value).toBe(0xFFFFFFFFFFFFFFFFn);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('produces distinct digest inputs for distinct commitments', () => {
|
|
164
|
+
lookupAuthDigest(1_700_000_000_000, '0x' + '01'.repeat(32));
|
|
165
|
+
lookupAuthDigest(1_700_000_000_000, '0x' + '02'.repeat(32));
|
|
166
|
+
|
|
167
|
+
// Call 0 is the cached domain-tag hash. Calls 1 and 2 are the two message
|
|
168
|
+
// hashes; the second call hits the domain-tag cache so it does not produce
|
|
169
|
+
// an extra hash call.
|
|
170
|
+
const messageCallA = hashCalls[1];
|
|
171
|
+
const messageCallB = hashCalls[2];
|
|
172
|
+
expect(messageCallA.input[5].value).not.toBe(messageCallB.input[5].value);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('produces distinct digest inputs for distinct timestamps', () => {
|
|
176
|
+
const keyCommitmentHex = '0x' + '01'.repeat(32);
|
|
177
|
+
lookupAuthDigest(1_700_000_000_000, keyCommitmentHex);
|
|
178
|
+
lookupAuthDigest(1_700_000_000_001, keyCommitmentHex);
|
|
179
|
+
|
|
180
|
+
const messageCallA = hashCalls[1];
|
|
181
|
+
const messageCallB = hashCalls[2];
|
|
182
|
+
expect(messageCallA.input[4].value).not.toBe(messageCallB.input[4].value);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Felt, FeltArray, Rpo256, Word } from '@miden-sdk/miden-sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lookup-bound message format used to sign requests against the Guardian
|
|
5
|
+
* `/state/lookup` endpoint. MUST produce byte-identical digests to
|
|
6
|
+
* `crates/shared/src/lookup_auth_message.rs`; parity is verified against
|
|
7
|
+
* `crates/shared/tests/fixtures/lookup_auth_vectors.json`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DOMAIN_TAG_BYTES = new TextEncoder().encode('guardian.lookup.v1');
|
|
11
|
+
|
|
12
|
+
let cachedDomainTag: Word | null = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert a byte array to a sequence of Goldilocks-field felts using the same
|
|
16
|
+
* 8-byte little-endian chunking convention as
|
|
17
|
+
* `guardian_shared::auth_request_payload::AuthRequestPayload::from_bytes`. The
|
|
18
|
+
* final chunk is zero-padded if `bytes.length` is not a multiple of 8.
|
|
19
|
+
*/
|
|
20
|
+
function bytesToFelts(bytes: Uint8Array): Felt[] {
|
|
21
|
+
const felts: Felt[] = [];
|
|
22
|
+
for (let offset = 0; offset < bytes.length; offset += 8) {
|
|
23
|
+
let value = 0n;
|
|
24
|
+
for (let i = 0; i < 8; i += 1) {
|
|
25
|
+
const byte = offset + i < bytes.length ? bytes[offset + i] : 0;
|
|
26
|
+
value |= BigInt(byte) << BigInt(8 * i);
|
|
27
|
+
}
|
|
28
|
+
felts.push(new Felt(value));
|
|
29
|
+
}
|
|
30
|
+
return felts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The 4-felt RPO domain tag prepended to every lookup digest, computed once
|
|
35
|
+
* from `DOMAIN_TAG_BYTES`. Cached on first call.
|
|
36
|
+
*/
|
|
37
|
+
function lookupDomainTag(): Word {
|
|
38
|
+
if (cachedDomainTag === null) {
|
|
39
|
+
cachedDomainTag = Rpo256.hashElements(new FeltArray(bytesToFelts(DOMAIN_TAG_BYTES)));
|
|
40
|
+
}
|
|
41
|
+
return cachedDomainTag;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compute the digest a lookup-bound signer signs.
|
|
46
|
+
*
|
|
47
|
+
* Layout (must mirror `LookupAuthMessage::to_word` in `crates/shared`):
|
|
48
|
+
*
|
|
49
|
+
* ```text
|
|
50
|
+
* RPO256_hash([
|
|
51
|
+
* DOMAIN_TAG_W0, DOMAIN_TAG_W1, DOMAIN_TAG_W2, DOMAIN_TAG_W3,
|
|
52
|
+
* timestamp_ms_felt,
|
|
53
|
+
* key_commitment_W0, key_commitment_W1,
|
|
54
|
+
* key_commitment_W2, key_commitment_W3,
|
|
55
|
+
* ])
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @param timestampMs Unix milliseconds. Reinterpreted as `u64` to match the
|
|
59
|
+
* Rust `as u64` cast (so negative inputs wrap into the
|
|
60
|
+
* high range, then are reduced mod the Goldilocks prime by
|
|
61
|
+
* the Felt constructor).
|
|
62
|
+
* @param keyCommitmentHex `0x`-prefixed 32-byte hex string for the queried
|
|
63
|
+
* commitment.
|
|
64
|
+
*/
|
|
65
|
+
export function lookupAuthDigest(timestampMs: number | bigint, keyCommitmentHex: string): Word {
|
|
66
|
+
const tag = lookupDomainTag().toFelts();
|
|
67
|
+
const kc = Word.fromHex(keyCommitmentHex).toFelts();
|
|
68
|
+
const tsBigInt = typeof timestampMs === 'bigint' ? timestampMs : BigInt(timestampMs);
|
|
69
|
+
const timestampU64 = BigInt.asUintN(64, tsBigInt);
|
|
70
|
+
const timestampFelt = new Felt(timestampU64);
|
|
71
|
+
|
|
72
|
+
const message = new FeltArray([
|
|
73
|
+
tag[0],
|
|
74
|
+
tag[1],
|
|
75
|
+
tag[2],
|
|
76
|
+
tag[3],
|
|
77
|
+
timestampFelt,
|
|
78
|
+
kc[0],
|
|
79
|
+
kc[1],
|
|
80
|
+
kc[2],
|
|
81
|
+
kc[3],
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
return Rpo256.hashElements(message);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reset the cached domain tag. Tests use this to exercise the cache miss path;
|
|
89
|
+
* production callers should not need it.
|
|
90
|
+
*/
|
|
91
|
+
export function _resetLookupDomainTagCacheForTesting(): void {
|
|
92
|
+
cachedDomainTag = null;
|
|
93
|
+
}
|
package/src/signers/ecdsa.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AccountId, AuthSecretKey, type MidenClient, type Word } from '@miden-sd
|
|
|
3
3
|
import type { Signer, SignatureScheme } from '../types.js';
|
|
4
4
|
import { bytesToHex, normalizeHexWord } from '../utils/encoding.js';
|
|
5
5
|
import { AuthDigest } from '../utils/digest.js';
|
|
6
|
+
import { lookupAuthDigest } from '../lookupAuth.js';
|
|
6
7
|
|
|
7
8
|
export class EcdsaSigner implements Signer {
|
|
8
9
|
readonly commitment: string;
|
|
@@ -39,6 +40,15 @@ export class EcdsaSigner implements Signer {
|
|
|
39
40
|
return this.signWord(word);
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Sign a `LookupAuthMessage` digest for the `/state/lookup` endpoint.
|
|
45
|
+
* Account-less; used directly by `recoverByKey`.
|
|
46
|
+
*/
|
|
47
|
+
async signLookupMessage(keyCommitmentHex: string, timestampMs: number): Promise<string> {
|
|
48
|
+
const digest = lookupAuthDigest(timestampMs, keyCommitmentHex);
|
|
49
|
+
return this.signWord(digest);
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
async bindAccountKey(midenClient: MidenClient, accountId: string): Promise<void> {
|
|
43
53
|
const targetAccountId = AccountId.fromHex(accountId);
|
|
44
54
|
const existingAccountId = await midenClient.keystore.getAccountId(this.publicKeyCommitment);
|
package/src/signers/falcon.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AccountId, AuthSecretKey, type MidenClient, type Word } from '@miden-sd
|
|
|
3
3
|
import type { Signer, SignatureScheme } from '../types.js';
|
|
4
4
|
import { bytesToHex } from '../utils/encoding.js';
|
|
5
5
|
import { AuthDigest } from '../utils/digest.js';
|
|
6
|
+
import { lookupAuthDigest } from '../lookupAuth.js';
|
|
6
7
|
|
|
7
8
|
export class FalconSigner implements Signer {
|
|
8
9
|
readonly commitment: string;
|
|
@@ -40,6 +41,15 @@ export class FalconSigner implements Signer {
|
|
|
40
41
|
return this.signWord(word);
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Sign a `LookupAuthMessage` digest for the `/state/lookup` endpoint.
|
|
46
|
+
* Account-less; used directly by `recoverByKey`.
|
|
47
|
+
*/
|
|
48
|
+
async signLookupMessage(keyCommitmentHex: string, timestampMs: number): Promise<string> {
|
|
49
|
+
const digest = lookupAuthDigest(timestampMs, keyCommitmentHex);
|
|
50
|
+
return this.signWord(digest);
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
async bindAccountKey(midenClient: MidenClient, accountId: string): Promise<void> {
|
|
44
54
|
const targetAccountId = AccountId.fromHex(accountId);
|
|
45
55
|
const existingAccountId = await midenClient.keystore.getAccountId(this.publicKeyCommitment);
|
|
@@ -153,6 +153,54 @@ describe('MidenWalletSigner', () => {
|
|
|
153
153
|
});
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
describe('signLookupMessage', () => {
|
|
157
|
+
it('delegates to localAuthSigner when present', async () => {
|
|
158
|
+
const localSigner: Signer = {
|
|
159
|
+
commitment: '0xlocal',
|
|
160
|
+
publicKey: '0xlocalpubkey',
|
|
161
|
+
scheme: 'ecdsa',
|
|
162
|
+
signAccountIdWithTimestamp: vi.fn(),
|
|
163
|
+
signCommitment: vi.fn(),
|
|
164
|
+
signLookupMessage: vi.fn().mockResolvedValue('0xlocallookupsig'),
|
|
165
|
+
};
|
|
166
|
+
const signer = new MidenWalletSigner(mockWallet, '0xcommitment', 'ecdsa', localSigner);
|
|
167
|
+
|
|
168
|
+
const result = await signer.signLookupMessage('0x' + 'cc'.repeat(32), 1700000000);
|
|
169
|
+
|
|
170
|
+
expect(result).toBe('0xlocallookupsig');
|
|
171
|
+
expect(localSigner.signLookupMessage).toHaveBeenCalledWith(
|
|
172
|
+
'0x' + 'cc'.repeat(32),
|
|
173
|
+
1700000000,
|
|
174
|
+
);
|
|
175
|
+
expect(mockWallet.signBytes).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('uses wallet signing over the LookupAuthMessage digest when no localAuthSigner', async () => {
|
|
179
|
+
const signer = new MidenWalletSigner(mockWallet, '0xcommitment', 'falcon');
|
|
180
|
+
|
|
181
|
+
const result = await signer.signLookupMessage('0x' + 'cc'.repeat(32), 1700000000);
|
|
182
|
+
|
|
183
|
+
expect(mockWallet.signBytes).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(mockWallet.signBytes).toHaveBeenCalledWith(expect.any(Uint8Array), 'word');
|
|
185
|
+
expect(result).toMatch(/^0x/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('falls back to wallet signing when localAuthSigner does not implement signLookupMessage', async () => {
|
|
189
|
+
const localSigner: Signer = {
|
|
190
|
+
commitment: '0xlocal',
|
|
191
|
+
publicKey: '0xlocalpubkey',
|
|
192
|
+
scheme: 'ecdsa',
|
|
193
|
+
signAccountIdWithTimestamp: vi.fn(),
|
|
194
|
+
signCommitment: vi.fn(),
|
|
195
|
+
};
|
|
196
|
+
const signer = new MidenWalletSigner(mockWallet, '0xcommitment', 'ecdsa', localSigner);
|
|
197
|
+
|
|
198
|
+
await signer.signLookupMessage('0x' + 'cc'.repeat(32), 1700000000);
|
|
199
|
+
|
|
200
|
+
expect(mockWallet.signBytes).toHaveBeenCalledTimes(1);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
156
204
|
describe('signature byte stripping', () => {
|
|
157
205
|
it('should strip the first byte from wallet signature response', async () => {
|
|
158
206
|
(mockWallet.signBytes as ReturnType<typeof vi.fn>).mockResolvedValue(
|
|
@@ -2,6 +2,7 @@ import type { RequestAuthPayload } from '@openzeppelin/guardian-client';
|
|
|
2
2
|
import type { Signer, SignatureScheme } from '../types.js';
|
|
3
3
|
import { AuthDigest } from '../utils/digest.js';
|
|
4
4
|
import { bytesToHex } from '../utils/encoding.js';
|
|
5
|
+
import { lookupAuthDigest } from '../lookupAuth.js';
|
|
5
6
|
import { wordToBytes } from '../utils/word.js';
|
|
6
7
|
|
|
7
8
|
export interface WalletSigningContext {
|
|
@@ -57,6 +58,18 @@ export class MidenWalletSigner implements Signer {
|
|
|
57
58
|
return this.signWord(word);
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Sign a `LookupAuthMessage` digest for the `/state/lookup` endpoint.
|
|
63
|
+
* Account-less; used directly by `recoverByKey`.
|
|
64
|
+
*/
|
|
65
|
+
async signLookupMessage(keyCommitmentHex: string, timestampMs: number): Promise<string> {
|
|
66
|
+
if (this.localAuthSigner?.signLookupMessage) {
|
|
67
|
+
return this.localAuthSigner.signLookupMessage(keyCommitmentHex, timestampMs);
|
|
68
|
+
}
|
|
69
|
+
const digest = lookupAuthDigest(timestampMs, keyCommitmentHex);
|
|
70
|
+
return this.signWord(digest);
|
|
71
|
+
}
|
|
72
|
+
|
|
60
73
|
private async signWord(word: { toFelts: () => Array<{ asInt: () => bigint }> }): Promise<string> {
|
|
61
74
|
const bytes = wordToBytes(word);
|
|
62
75
|
const signatureBytes = await this.wallet.signBytes(bytes, 'word');
|
package/src/signers/para.test.ts
CHANGED
|
@@ -148,6 +148,32 @@ describe('ParaSigner', () => {
|
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
150
|
|
|
151
|
+
describe('signLookupMessage', () => {
|
|
152
|
+
it('hashes the LookupAuthMessage digest word bytes before signing', async () => {
|
|
153
|
+
const signer = new ParaSigner(mockPara, 'wallet-1', '0xcommitment', validCompressedKey);
|
|
154
|
+
await signer.signLookupMessage('0x' + 'cc'.repeat(32), 1700000000);
|
|
155
|
+
|
|
156
|
+
const expectedDigestBytes = keccak_256(
|
|
157
|
+
wordToBytes({
|
|
158
|
+
toFelts: () => [
|
|
159
|
+
{ asInt: () => 10n },
|
|
160
|
+
{ asInt: () => 20n },
|
|
161
|
+
{ asInt: () => 30n },
|
|
162
|
+
{ asInt: () => 40n },
|
|
163
|
+
],
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
const expectedBase64 = uint8ArrayToBase64(expectedDigestBytes);
|
|
167
|
+
|
|
168
|
+
expect(mockPara.signMessage).toHaveBeenCalledWith(
|
|
169
|
+
expect.objectContaining({
|
|
170
|
+
walletId: 'wallet-1',
|
|
171
|
+
messageBase64: expectedBase64,
|
|
172
|
+
}),
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
151
177
|
describe('signing denial', () => {
|
|
152
178
|
it('should throw when para returns no signature', async () => {
|
|
153
179
|
(mockPara.signMessage as ReturnType<typeof vi.fn>).mockResolvedValue({ signature: undefined });
|
package/src/signers/para.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Signer, SignatureScheme } from '../types.js';
|
|
|
3
3
|
import { AuthDigest } from '../utils/digest.js';
|
|
4
4
|
import { EcdsaFormat } from '../utils/ecdsa.js';
|
|
5
5
|
import { hexToBytes, uint8ArrayToBase64 } from '../utils/encoding.js';
|
|
6
|
+
import { lookupAuthDigest } from '../lookupAuth.js';
|
|
6
7
|
import { wordToBytes } from '../utils/word.js';
|
|
7
8
|
|
|
8
9
|
export interface ParaSigningContext {
|
|
@@ -59,6 +60,15 @@ export class ParaSigner implements Signer {
|
|
|
59
60
|
return this.signWord(word);
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Sign a `LookupAuthMessage` digest for the `/state/lookup` endpoint.
|
|
65
|
+
* Account-less; used directly by `recoverByKey`.
|
|
66
|
+
*/
|
|
67
|
+
async signLookupMessage(keyCommitmentHex: string, timestampMs: number): Promise<string> {
|
|
68
|
+
const digest = lookupAuthDigest(timestampMs, keyCommitmentHex);
|
|
69
|
+
return this.signWord(digest);
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
private async signWord(word: { toHex: () => string; toFelts: () => Array<{ asInt: () => bigint }> }): Promise<string> {
|
|
63
73
|
const messageBase64 = uint8ArrayToBase64(
|
|
64
74
|
hexToBytes(EcdsaFormat.keccakDigestHex(wordToBytes(word))),
|