@openzeppelin/miden-multisig-client 0.18.0 → 0.18.2
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 +176 -23
- package/dist/client.d.ts +41 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +79 -2
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/lookupAuth.d.ts.map +1 -1
- package/dist/lookupAuth.js +2 -20
- package/dist/lookupAuth.js.map +1 -1
- package/dist/multisig/authArgErrors.d.ts +27 -1
- package/dist/multisig/authArgErrors.d.ts.map +1 -1
- package/dist/multisig/authArgErrors.js +33 -0
- package/dist/multisig/authArgErrors.js.map +1 -1
- package/dist/multisig.d.ts +77 -11
- package/dist/multisig.d.ts.map +1 -1
- package/dist/multisig.js +247 -181
- package/dist/multisig.js.map +1 -1
- package/dist/proposal/metadata.d.ts.map +1 -1
- package/dist/proposal/metadata.js +2 -0
- package/dist/proposal/metadata.js.map +1 -1
- package/dist/recovery/transportDrain.d.ts +38 -30
- package/dist/recovery/transportDrain.d.ts.map +1 -1
- package/dist/recovery/transportDrain.js +113 -113
- package/dist/recovery/transportDrain.js.map +1 -1
- package/dist/session/grant.d.ts +40 -0
- package/dist/session/grant.d.ts.map +1 -0
- package/dist/session/grant.js +125 -0
- package/dist/session/grant.js.map +1 -0
- package/dist/session/key.d.ts +103 -0
- package/dist/session/key.d.ts.map +1 -0
- package/dist/session/key.js +160 -0
- package/dist/session/key.js.map +1 -0
- package/dist/session/manager.d.ts +76 -0
- package/dist/session/manager.d.ts.map +1 -0
- package/dist/session/manager.js +187 -0
- package/dist/session/manager.js.map +1 -0
- package/dist/signers/ecdsa.d.ts +5 -1
- 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 -1
- 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/ledger.d.ts +9 -1
- package/dist/signers/ledger.d.ts.map +1 -1
- package/dist/signers/ledger.js +13 -1
- package/dist/signers/ledger.js.map +1 -1
- package/dist/signers/miden-wallet.d.ts +13 -1
- package/dist/signers/miden-wallet.d.ts.map +1 -1
- package/dist/signers/miden-wallet.js +25 -0
- package/dist/signers/miden-wallet.js.map +1 -1
- package/dist/signers/para.d.ts +5 -1
- 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/transaction/authArgs.d.ts +2 -2
- package/dist/transaction/authArgs.js +2 -2
- package/dist/transaction/options.d.ts +2 -2
- package/dist/transaction/summary.d.ts +17 -61
- package/dist/transaction/summary.d.ts.map +1 -1
- package/dist/transaction/summary.js +49 -95
- package/dist/transaction/summary.js.map +1 -1
- package/dist/transaction.d.ts +1 -1
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +1 -1
- package/dist/transaction.js.map +1 -1
- package/dist/types/proposal.d.ts +11 -5
- package/dist/types/proposal.d.ts.map +1 -1
- package/dist/types/proposal.js.map +1 -1
- package/dist/utils/eip712.d.ts +26 -69
- package/dist/utils/eip712.d.ts.map +1 -1
- package/dist/utils/eip712.js +101 -8
- package/dist/utils/eip712.js.map +1 -1
- package/dist/utils/felt.d.ts +7 -1
- package/dist/utils/felt.d.ts.map +1 -1
- package/dist/utils/felt.js +18 -1
- package/dist/utils/felt.js.map +1 -1
- package/package.json +4 -4
- package/src/client.ts +97 -2
- package/src/index.ts +29 -8
- package/src/lookupAuth.ts +3 -22
- package/src/multisig/authArgErrors.ts +42 -1
- package/src/multisig.ts +318 -207
- package/src/proposal/metadata.ts +2 -0
- package/src/recovery/transportDrain.ts +118 -124
- package/src/session/grant.ts +143 -0
- package/src/session/key.ts +215 -0
- package/src/session/manager.ts +273 -0
- package/src/signers/ecdsa.ts +12 -1
- package/src/signers/falcon.ts +12 -1
- package/src/signers/ledger.ts +19 -2
- package/src/signers/miden-wallet.ts +29 -1
- package/src/signers/para.ts +12 -1
- package/src/transaction/authArgs.ts +2 -2
- package/src/transaction/options.ts +2 -2
- package/src/transaction/summary.ts +62 -110
- package/src/transaction.ts +1 -6
- package/src/types/proposal.ts +11 -5
- package/src/utils/eip712.ts +130 -10
- package/src/utils/felt.ts +19 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RequestAuthPayload,
|
|
3
|
+
SessionEndReason,
|
|
4
|
+
SessionRequestSigner,
|
|
5
|
+
} from '@openzeppelin/guardian-client';
|
|
6
|
+
import type { Word } from '@miden-sdk/miden-sdk';
|
|
7
|
+
import { AuthDigest } from '../utils/digest.js';
|
|
8
|
+
import { EcdsaFormat } from '../utils/ecdsa.js';
|
|
9
|
+
import { bytesToHex } from '../utils/encoding.js';
|
|
10
|
+
import { wordToBytes } from '../utils/word.js';
|
|
11
|
+
import { sessionLogoutDigest } from './grant.js';
|
|
12
|
+
|
|
13
|
+
const ALGORITHM = { name: 'ECDSA', namedCurve: 'P-256' } as const;
|
|
14
|
+
const SIGNATURE = { name: 'ECDSA', hash: 'SHA-256' } as const;
|
|
15
|
+
|
|
16
|
+
function subtle(): SubtleCrypto {
|
|
17
|
+
if (!globalThis.crypto?.subtle) {
|
|
18
|
+
throw new Error('Guardian sessions require WebCrypto (crypto.subtle)');
|
|
19
|
+
}
|
|
20
|
+
return globalThis.crypto.subtle;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A P-256 session key whose private half is a non-extractable WebCrypto key:
|
|
25
|
+
* page scripts can use it to sign while it is loaded, but can never read or
|
|
26
|
+
* export it.
|
|
27
|
+
*/
|
|
28
|
+
export class WebCryptoSessionKey {
|
|
29
|
+
constructor(
|
|
30
|
+
readonly privateKey: CryptoKey,
|
|
31
|
+
/** Hex SEC1-compressed public key. */
|
|
32
|
+
readonly publicKey: string,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
static async generate(): Promise<WebCryptoSessionKey> {
|
|
36
|
+
const pair = await subtle().generateKey(ALGORITHM, false, ['sign', 'verify']);
|
|
37
|
+
const raw = new Uint8Array(await subtle().exportKey('raw', pair.publicKey));
|
|
38
|
+
return new WebCryptoSessionKey(pair.privateKey, EcdsaFormat.compressPublicKey(bytesToHex(raw)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Sign the 32 bytes of a word: ECDSA P-256 over SHA-256, `r || s` hex. */
|
|
42
|
+
async signWord(word: Word): Promise<string> {
|
|
43
|
+
const message = new Uint8Array(wordToBytes(word));
|
|
44
|
+
const signature = await subtle().sign(SIGNATURE, this.privateKey, message);
|
|
45
|
+
return bytesToHex(new Uint8Array(signature));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface GuardianSessionOptions {
|
|
50
|
+
/** The store keeping this session's key; its record is removed when the session ends. */
|
|
51
|
+
store?: SessionKeyStore;
|
|
52
|
+
/**
|
|
53
|
+
* Called once when the Guardian client stops using the session. Requests
|
|
54
|
+
* are then signed by the wallet again. On `expired`, `revoked` or
|
|
55
|
+
* `rejected`, start a new session to stop the prompts; `logout` means the
|
|
56
|
+
* app ended it itself (logout, revoke-all or a replacement session).
|
|
57
|
+
*/
|
|
58
|
+
onEnded?: (reason: SessionEndReason, session: GuardianSession) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Signs Guardian requests for a wallet after its session grant was
|
|
63
|
+
* registered. Plug into `GuardianHttpClient.setSession`.
|
|
64
|
+
*/
|
|
65
|
+
export class GuardianSession implements SessionRequestSigner {
|
|
66
|
+
constructor(
|
|
67
|
+
private readonly key: WebCryptoSessionKey,
|
|
68
|
+
/** Commitment of the wallet that signed the grant. */
|
|
69
|
+
readonly signerCommitment: string,
|
|
70
|
+
/** Unix seconds. */
|
|
71
|
+
readonly expiresAt: number,
|
|
72
|
+
/** Where a `SessionKeyStore` keeps this session's key. */
|
|
73
|
+
readonly storeId: string,
|
|
74
|
+
private readonly options: GuardianSessionOptions = {},
|
|
75
|
+
) {}
|
|
76
|
+
|
|
77
|
+
get publicKey(): string {
|
|
78
|
+
return this.key.publicKey;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Forgets the stored key (best effort: a dead key left behind fails its
|
|
83
|
+
* first request after a reload and ends then) and tells the app.
|
|
84
|
+
*/
|
|
85
|
+
onEnded(reason: SessionEndReason): void {
|
|
86
|
+
if (this.options.store) {
|
|
87
|
+
void forgetSessionKey(this.options.store, this).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
this.options.onEnded?.(reason, this);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
signRequest(
|
|
93
|
+
accountId: string,
|
|
94
|
+
timestamp: number,
|
|
95
|
+
requestPayload: RequestAuthPayload,
|
|
96
|
+
): Promise<string> {
|
|
97
|
+
return this.key.signWord(AuthDigest.fromRequest(accountId, timestamp, requestPayload));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
signLogout(timestampMs: number): Promise<string> {
|
|
101
|
+
return this.key.signWord(sessionLogoutDigest(this.key.publicKey, timestampMs));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A session key persisted across page loads. */
|
|
106
|
+
export interface StoredSessionKey {
|
|
107
|
+
/** `<guardian key commitment>:<signer commitment>`. */
|
|
108
|
+
id: string;
|
|
109
|
+
privateKey: CryptoKey;
|
|
110
|
+
publicKey: string;
|
|
111
|
+
/** Unix seconds. */
|
|
112
|
+
expiresAt: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface SessionKeyStore {
|
|
116
|
+
load(id: string): Promise<StoredSessionKey | null>;
|
|
117
|
+
save(record: StoredSessionKey): Promise<void>;
|
|
118
|
+
/**
|
|
119
|
+
* Remove the record `id` only if it still holds `publicKey`, as one atomic
|
|
120
|
+
* step, so a newer key another tab saved for the same wallet and Guardian is
|
|
121
|
+
* never deleted.
|
|
122
|
+
*/
|
|
123
|
+
remove(id: string, publicKey: string): Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Remove the stored key of `session`, unless the record already holds the key
|
|
128
|
+
* of a newer session for the same wallet and Guardian.
|
|
129
|
+
*/
|
|
130
|
+
export async function forgetSessionKey(
|
|
131
|
+
store: SessionKeyStore,
|
|
132
|
+
session: { storeId: string; publicKey: string },
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
await store.remove(session.storeId, session.publicKey);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Keeps session keys in IndexedDB so a reload does not need another wallet
|
|
139
|
+
* signature. The browser stores the `CryptoKey` object itself; it stays
|
|
140
|
+
* non-extractable.
|
|
141
|
+
*/
|
|
142
|
+
export class IndexedDbSessionKeyStore implements SessionKeyStore {
|
|
143
|
+
private static readonly STORE = 'sessions';
|
|
144
|
+
private database: Promise<IDBDatabase> | null = null;
|
|
145
|
+
|
|
146
|
+
constructor(private readonly databaseName = 'guardian-sessions') {}
|
|
147
|
+
|
|
148
|
+
async load(id: string): Promise<StoredSessionKey | null> {
|
|
149
|
+
const record = await this.request<StoredSessionKey | undefined>('readonly', (store) =>
|
|
150
|
+
store.get(id),
|
|
151
|
+
);
|
|
152
|
+
return record ?? null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async save(record: StoredSessionKey): Promise<void> {
|
|
156
|
+
await this.request('readwrite', (store) => store.put(record));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async remove(id: string, publicKey: string): Promise<void> {
|
|
160
|
+
const database = await this.open();
|
|
161
|
+
return new Promise<void>((resolve, reject) => {
|
|
162
|
+
const transaction = database.transaction(IndexedDbSessionKeyStore.STORE, 'readwrite');
|
|
163
|
+
const store = transaction.objectStore(IndexedDbSessionKeyStore.STORE);
|
|
164
|
+
const lookup = store.get(id);
|
|
165
|
+
lookup.onsuccess = () => {
|
|
166
|
+
if ((lookup.result as StoredSessionKey | undefined)?.publicKey === publicKey) {
|
|
167
|
+
store.delete(id);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
transaction.oncomplete = () => resolve();
|
|
171
|
+
transaction.onerror = () => reject(transaction.error);
|
|
172
|
+
transaction.onabort = () => reject(transaction.error);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
private async request<T>(
|
|
178
|
+
mode: IDBTransactionMode,
|
|
179
|
+
run: (store: IDBObjectStore) => IDBRequest,
|
|
180
|
+
): Promise<T> {
|
|
181
|
+
const database = await this.open();
|
|
182
|
+
return new Promise<T>((resolve, reject) => {
|
|
183
|
+
const transaction = database.transaction(IndexedDbSessionKeyStore.STORE, mode);
|
|
184
|
+
const request = run(transaction.objectStore(IndexedDbSessionKeyStore.STORE));
|
|
185
|
+
transaction.oncomplete = () => resolve(request.result as T);
|
|
186
|
+
transaction.onerror = () => reject(transaction.error ?? request.error);
|
|
187
|
+
transaction.onabort = () => reject(transaction.error ?? request.error);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Opens the database once; a failed open is retried on the next call. */
|
|
192
|
+
private open(): Promise<IDBDatabase> {
|
|
193
|
+
if (!this.database) {
|
|
194
|
+
const opening = new Promise<IDBDatabase>((resolve, reject) => {
|
|
195
|
+
if (!globalThis.indexedDB) {
|
|
196
|
+
reject(new Error('IndexedDB is not available'));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const request = globalThis.indexedDB.open(this.databaseName, 1);
|
|
200
|
+
request.onupgradeneeded = () => {
|
|
201
|
+
request.result.createObjectStore(IndexedDbSessionKeyStore.STORE, { keyPath: 'id' });
|
|
202
|
+
};
|
|
203
|
+
request.onsuccess = () => resolve(request.result);
|
|
204
|
+
request.onerror = () => reject(request.error);
|
|
205
|
+
});
|
|
206
|
+
opening.catch(() => {
|
|
207
|
+
if (this.database === opening) {
|
|
208
|
+
this.database = null;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
this.database = opening;
|
|
212
|
+
}
|
|
213
|
+
return this.database;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GuardianHttpClient,
|
|
3
|
+
SessionGrantFields,
|
|
4
|
+
Signer,
|
|
5
|
+
} from '@openzeppelin/guardian-client';
|
|
6
|
+
import {
|
|
7
|
+
GuardianSession,
|
|
8
|
+
type GuardianSessionOptions,
|
|
9
|
+
type SessionKeyStore,
|
|
10
|
+
type StoredSessionKey,
|
|
11
|
+
WebCryptoSessionKey,
|
|
12
|
+
forgetSessionKey,
|
|
13
|
+
} from './key.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A grant is shortened by this much below the Guardian's maximum lifetime so
|
|
17
|
+
* client clock drift cannot push it over the limit.
|
|
18
|
+
*/
|
|
19
|
+
const MAX_TTL_MARGIN_SECONDS = 60;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Guardian accepts a grant only while it is at most this old, and only if it
|
|
23
|
+
* expires more than this far in the future.
|
|
24
|
+
*/
|
|
25
|
+
const GRANT_WINDOW_SECONDS = 300;
|
|
26
|
+
|
|
27
|
+
/** Matches `GuardianHttpClient`: a session this close to expiry is not used. */
|
|
28
|
+
const SESSION_EXPIRY_MARGIN_SECONDS = 30;
|
|
29
|
+
|
|
30
|
+
/** The Guardian does not advertise sessions on `GET /status`. */
|
|
31
|
+
export class GuardianSessionsUnsupportedError extends Error {
|
|
32
|
+
constructor() {
|
|
33
|
+
super('This Guardian does not accept sessions');
|
|
34
|
+
this.name = 'GuardianSessionsUnsupportedError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `confirm` returned `false`: the user declined the grant. */
|
|
39
|
+
export class SessionGrantDeclinedError extends Error {
|
|
40
|
+
constructor() {
|
|
41
|
+
super('Session grant was declined');
|
|
42
|
+
this.name = 'SessionGrantDeclinedError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StartSessionOptions extends Pick<GuardianSessionOptions, 'onEnded'> {
|
|
47
|
+
/**
|
|
48
|
+
* Requested lifetime, more than 300 seconds; capped below the Guardian's
|
|
49
|
+
* advertised maximum.
|
|
50
|
+
*/
|
|
51
|
+
ttlSeconds?: number;
|
|
52
|
+
/**
|
|
53
|
+
* The website asking for the session, shown to the user by the wallet.
|
|
54
|
+
* Defaults to the page's `location.origin` in a browser and to none
|
|
55
|
+
* elsewhere. Guardian does not check it against requests.
|
|
56
|
+
*/
|
|
57
|
+
origin?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Called with the grant before the wallet signs it; return `false` to
|
|
60
|
+
* abort. Required for raw (non-EIP-712) wallets, which show only a hash:
|
|
61
|
+
* display {@link describeSessionGrant} to the user here.
|
|
62
|
+
*/
|
|
63
|
+
confirm?: (grant: SessionGrantFields) => boolean | Promise<boolean>;
|
|
64
|
+
/** Persist the session key so a reload can `resume` without the wallet. */
|
|
65
|
+
store?: SessionKeyStore;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The current page's origin in a browser; empty elsewhere. */
|
|
69
|
+
function pageOrigin(): string {
|
|
70
|
+
const location = (globalThis as { location?: { origin?: string } }).location;
|
|
71
|
+
return location?.origin && location.origin !== 'null' ? location.origin : '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function storeId(guardianCommitment: string, signerCommitment: string): string {
|
|
75
|
+
return `${guardianCommitment.toLowerCase()}:${signerCommitment.toLowerCase()}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function nowSeconds(): number {
|
|
79
|
+
return Math.floor(Date.now() / 1000);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Start a Guardian session: `signer` signs one grant and, once the session is
|
|
84
|
+
* registered, becomes `guardian`'s signer; a non-extractable P-256 delegated
|
|
85
|
+
* signer then signs the
|
|
86
|
+
* session-eligible requests on `guardian` (reads, proposal list/get, proposal
|
|
87
|
+
* create/sign). Every other route, including `configure`, `pushDelta`,
|
|
88
|
+
* `abandonCandidate`, account lookup and `revokeAllSessions`, keeps using the
|
|
89
|
+
* wallet. A session already set on `guardian` is replaced but stays valid on
|
|
90
|
+
* the server until it expires; end it first with {@link endGuardianSession}.
|
|
91
|
+
*
|
|
92
|
+
* The grant is dated after `confirm` returns, so a slow confirmation still
|
|
93
|
+
* registers; only its start moves, the confirmed expiry stays. If storing the
|
|
94
|
+
* key fails, the registered session is logged out and the error rethrown.
|
|
95
|
+
*/
|
|
96
|
+
export async function startGuardianSession(
|
|
97
|
+
guardian: GuardianHttpClient,
|
|
98
|
+
signer: Signer,
|
|
99
|
+
options: StartSessionOptions = {},
|
|
100
|
+
): Promise<GuardianSession> {
|
|
101
|
+
if (!signer.signSessionGrant) {
|
|
102
|
+
throw new Error('This signer cannot sign Guardian session grants');
|
|
103
|
+
}
|
|
104
|
+
if (signer.requestAuthFormat !== 'eip712' && !options.confirm) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
'This wallet signs a session grant as a hash: pass `confirm` and show ' +
|
|
107
|
+
'describeSessionGrant(grant) to the user first',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const [status, { commitment: guardianCommitment }, key] = await Promise.all([
|
|
111
|
+
guardian.getStatus(),
|
|
112
|
+
guardian.getPubkey(signer.scheme),
|
|
113
|
+
WebCryptoSessionKey.generate(),
|
|
114
|
+
]);
|
|
115
|
+
if (!status.sessions) {
|
|
116
|
+
throw new GuardianSessionsUnsupportedError();
|
|
117
|
+
}
|
|
118
|
+
const maxTtl = status.sessions.maxTtlSeconds - MAX_TTL_MARGIN_SECONDS;
|
|
119
|
+
const ttl = Math.min(options.ttlSeconds ?? maxTtl, maxTtl);
|
|
120
|
+
if (!Number.isInteger(ttl) || ttl <= GRANT_WINDOW_SECONDS) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Session lifetime must be a whole number of seconds above ${GRANT_WINDOW_SECONDS}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const issuedAt = nowSeconds();
|
|
127
|
+
let grant: SessionGrantFields = {
|
|
128
|
+
signerCommitment: signer.commitment,
|
|
129
|
+
sessionPublicKey: key.publicKey,
|
|
130
|
+
origin: options.origin ?? pageOrigin(),
|
|
131
|
+
issuedAt,
|
|
132
|
+
expiresAt: issuedAt + ttl,
|
|
133
|
+
guardianCommitment,
|
|
134
|
+
network: status.environment,
|
|
135
|
+
};
|
|
136
|
+
if (options.confirm) {
|
|
137
|
+
if (!(await options.confirm(grant))) {
|
|
138
|
+
throw new SessionGrantDeclinedError();
|
|
139
|
+
}
|
|
140
|
+
grant = { ...grant, issuedAt: Math.max(issuedAt, nowSeconds()) };
|
|
141
|
+
if (grant.expiresAt - grant.issuedAt <= GRANT_WINDOW_SECONDS) {
|
|
142
|
+
throw new Error('The session grant was confirmed too late; start the session again');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const signature = await signer.signSessionGrant(grant);
|
|
146
|
+
if (nowSeconds() - grant.issuedAt > GRANT_WINDOW_SECONDS) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`The wallet took more than ${GRANT_WINDOW_SECONDS} seconds to sign the session grant; ` +
|
|
149
|
+
'start the session again',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
await guardian.createSession({
|
|
153
|
+
scheme: signer.scheme,
|
|
154
|
+
...(signer.requestAuthFormat === 'eip712' ? { authFormat: 'eip712' as const } : {}),
|
|
155
|
+
...(signer.scheme === 'ecdsa' ? { publicKey: signer.publicKey } : {}),
|
|
156
|
+
signature,
|
|
157
|
+
grant,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const session = new GuardianSession(
|
|
161
|
+
key,
|
|
162
|
+
signer.commitment,
|
|
163
|
+
grant.expiresAt,
|
|
164
|
+
storeId(guardianCommitment, signer.commitment),
|
|
165
|
+
{ store: options.store, onEnded: options.onEnded },
|
|
166
|
+
);
|
|
167
|
+
if (options.store) {
|
|
168
|
+
try {
|
|
169
|
+
await options.store.save({
|
|
170
|
+
id: session.storeId,
|
|
171
|
+
privateKey: key.privateKey,
|
|
172
|
+
publicKey: key.publicKey,
|
|
173
|
+
expiresAt: grant.expiresAt,
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const unreachable = new GuardianSession(key, signer.commitment, grant.expiresAt, session.storeId);
|
|
177
|
+
await guardian.revokeSession(unreachable).catch(() => false);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
guardian.setSigner(signer);
|
|
182
|
+
guardian.setSession(session);
|
|
183
|
+
return session;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Reuse a session key persisted by `startGuardianSession` without asking the
|
|
188
|
+
* wallet again; `signer` becomes `guardian`'s signer when a session resumes.
|
|
189
|
+
* Returns `null` when none
|
|
190
|
+
* is stored or it expires within 30 seconds. A session revoked elsewhere
|
|
191
|
+
* fails its next request with `session_revoked` and ends then (`onEnded`);
|
|
192
|
+
* start a new one.
|
|
193
|
+
*/
|
|
194
|
+
export async function resumeGuardianSession(
|
|
195
|
+
guardian: GuardianHttpClient,
|
|
196
|
+
signer: Signer,
|
|
197
|
+
store: SessionKeyStore,
|
|
198
|
+
options: Pick<GuardianSessionOptions, 'onEnded'> = {},
|
|
199
|
+
): Promise<GuardianSession | null> {
|
|
200
|
+
const { commitment: guardianCommitment } = await guardian.getPubkey(signer.scheme);
|
|
201
|
+
const id = storeId(guardianCommitment, signer.commitment);
|
|
202
|
+
const record = await store.load(id);
|
|
203
|
+
if (!record) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
if (record.expiresAt - SESSION_EXPIRY_MARGIN_SECONDS <= nowSeconds()) {
|
|
207
|
+
await store.remove(id, record.publicKey);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const session = new GuardianSession(
|
|
211
|
+
new WebCryptoSessionKey(record.privateKey, record.publicKey),
|
|
212
|
+
signer.commitment,
|
|
213
|
+
record.expiresAt,
|
|
214
|
+
id,
|
|
215
|
+
{ store, onEnded: options.onEnded },
|
|
216
|
+
);
|
|
217
|
+
guardian.setSigner(signer);
|
|
218
|
+
guardian.setSession(session);
|
|
219
|
+
return session;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Revoke `session` on the server, stop using it if it is the current one, and
|
|
224
|
+
* forget its stored key. Returns whether the server still had it active. On
|
|
225
|
+
* error nothing changes.
|
|
226
|
+
*/
|
|
227
|
+
export async function endGuardianSession(
|
|
228
|
+
guardian: GuardianHttpClient,
|
|
229
|
+
session: GuardianSession,
|
|
230
|
+
store?: SessionKeyStore,
|
|
231
|
+
): Promise<boolean> {
|
|
232
|
+
const revoked = await guardian.revokeSession(session);
|
|
233
|
+
if (store) {
|
|
234
|
+
await forgetSessionKey(store, session);
|
|
235
|
+
}
|
|
236
|
+
return revoked;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Revoke every session of `signer` on the Guardian with a wallet signature,
|
|
241
|
+
* including sessions started in other tabs or devices, stop using the current
|
|
242
|
+
* one, and forget its stored key (a newer key another tab stored meanwhile
|
|
243
|
+
* is kept). `signer` becomes `guardian`'s signer. Use it when a session key
|
|
244
|
+
* may be compromised: page logout alone cannot stop a script that holds the
|
|
245
|
+
* key. Returns how many sessions the server revoked.
|
|
246
|
+
*
|
|
247
|
+
* It ends sessions already registered with Guardian. A page that got a grant
|
|
248
|
+
* signed can still register it for up to about 10 minutes, and a device whose
|
|
249
|
+
* clock runs behind can miss sessions started just before; run it again 10
|
|
250
|
+
* minutes later when a key may be compromised.
|
|
251
|
+
*/
|
|
252
|
+
export async function revokeAllGuardianSessions(
|
|
253
|
+
guardian: GuardianHttpClient,
|
|
254
|
+
signer: Signer,
|
|
255
|
+
store?: SessionKeyStore,
|
|
256
|
+
): Promise<number> {
|
|
257
|
+
guardian.setSigner(signer);
|
|
258
|
+
const stored = store ? await storedKey(guardian, signer, store) : null;
|
|
259
|
+
const revoked = await guardian.revokeAllSessions();
|
|
260
|
+
if (store && stored) {
|
|
261
|
+
await forgetSessionKey(store, { storeId: stored.id, publicKey: stored.publicKey });
|
|
262
|
+
}
|
|
263
|
+
return revoked;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function storedKey(
|
|
267
|
+
guardian: GuardianHttpClient,
|
|
268
|
+
signer: Signer,
|
|
269
|
+
store: SessionKeyStore,
|
|
270
|
+
): Promise<StoredSessionKey | null> {
|
|
271
|
+
const { commitment: guardianCommitment } = await guardian.getPubkey(signer.scheme);
|
|
272
|
+
return store.load(storeId(guardianCommitment, signer.commitment));
|
|
273
|
+
}
|
package/src/signers/ecdsa.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { RequestAuthPayload } from '@openzeppelin/guardian-client';
|
|
1
|
+
import type { RequestAuthPayload, SessionGrantFields } from '@openzeppelin/guardian-client';
|
|
2
2
|
import { AccountId, AuthSecretKey, type MidenClient, type Word } from '@miden-sdk/miden-sdk';
|
|
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
6
|
import { lookupAuthDigest } from '../lookupAuth.js';
|
|
7
|
+
import { sessionGrantDigest, sessionRevokeAllDigest } from '../session/grant.js';
|
|
7
8
|
|
|
8
9
|
export class EcdsaSigner implements Signer {
|
|
9
10
|
readonly commitment: string;
|
|
@@ -49,6 +50,16 @@ export class EcdsaSigner implements Signer {
|
|
|
49
50
|
return this.signWord(digest);
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Sign a session grant as its raw RPO digest. */
|
|
54
|
+
async signSessionGrant(grant: SessionGrantFields): Promise<string> {
|
|
55
|
+
return this.signWord(sessionGrantDigest(grant));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Sign a session revoke-all as its raw RPO digest. */
|
|
59
|
+
async signSessionRevokeAll(signerCommitment: string, timestampMs: number): Promise<string> {
|
|
60
|
+
return this.signWord(sessionRevokeAllDigest(signerCommitment, timestampMs));
|
|
61
|
+
}
|
|
62
|
+
|
|
52
63
|
async bindAccountKey(midenClient: MidenClient, accountId: string): Promise<void> {
|
|
53
64
|
const targetAccountId = AccountId.fromHex(accountId);
|
|
54
65
|
const existingAccountId = await midenClient.keystore.getAccountId(this.publicKeyCommitment);
|
package/src/signers/falcon.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { RequestAuthPayload } from '@openzeppelin/guardian-client';
|
|
1
|
+
import type { RequestAuthPayload, SessionGrantFields } from '@openzeppelin/guardian-client';
|
|
2
2
|
import { AccountId, AuthSecretKey, type MidenClient, type Word } from '@miden-sdk/miden-sdk';
|
|
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
6
|
import { lookupAuthDigest } from '../lookupAuth.js';
|
|
7
|
+
import { sessionGrantDigest, sessionRevokeAllDigest } from '../session/grant.js';
|
|
7
8
|
|
|
8
9
|
export class FalconSigner implements Signer {
|
|
9
10
|
readonly commitment: string;
|
|
@@ -50,6 +51,16 @@ export class FalconSigner implements Signer {
|
|
|
50
51
|
return this.signWord(digest);
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** Sign a session grant as its raw RPO digest. */
|
|
55
|
+
async signSessionGrant(grant: SessionGrantFields): Promise<string> {
|
|
56
|
+
return this.signWord(sessionGrantDigest(grant));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Sign a session revoke-all as its raw RPO digest. */
|
|
60
|
+
async signSessionRevokeAll(signerCommitment: string, timestampMs: number): Promise<string> {
|
|
61
|
+
return this.signWord(sessionRevokeAllDigest(signerCommitment, timestampMs));
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
async bindAccountKey(midenClient: MidenClient, accountId: string): Promise<void> {
|
|
54
65
|
const targetAccountId = AccountId.fromHex(accountId);
|
|
55
66
|
const existingAccountId = await midenClient.keystore.getAccountId(this.publicKeyCommitment);
|
package/src/signers/ledger.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
2
2
|
import { keccak_256 } from '@noble/hashes/sha3.js';
|
|
3
|
-
import type { RequestAuthPayload, Signer } from '@openzeppelin/guardian-client';
|
|
3
|
+
import type { RequestAuthPayload, SessionGrantFields, Signer } from '@openzeppelin/guardian-client';
|
|
4
4
|
import { AuthDigest } from '../utils/digest.js';
|
|
5
5
|
import { lookupAuthDigest } from '../lookupAuth.js';
|
|
6
6
|
import { EcdsaFormat } from '../utils/ecdsa.js';
|
|
7
7
|
import { bytesToHex, hexToBytes } from '../utils/encoding.js';
|
|
8
8
|
import {
|
|
9
|
+
type GuardianTypedData,
|
|
9
10
|
guardianKeyDiscoveryTypedData,
|
|
10
11
|
guardianLookupTypedData,
|
|
11
12
|
guardianRequestTypedData,
|
|
13
|
+
guardianSessionRevokeAllTypedData,
|
|
14
|
+
guardianSessionTypedData,
|
|
12
15
|
midenTransactionTypedData,
|
|
13
16
|
typedDataDigest,
|
|
14
17
|
} from '../utils/eip712.js';
|
|
@@ -99,7 +102,21 @@ export class Eip712Signer implements Signer {
|
|
|
99
102
|
return this.signTypedData(midenTransactionTypedData(wordToBytes(commitment)));
|
|
100
103
|
}
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Sign a session grant as readable `GuardianSession` typed
|
|
107
|
+
* data, so the device shows the Guardian, network and expiry rather than a
|
|
108
|
+
* hash. Devices without registered display metadata need "Verbose EIP-712".
|
|
109
|
+
*/
|
|
110
|
+
async signSessionGrant(grant: SessionGrantFields): Promise<string> {
|
|
111
|
+
return this.signTypedData(guardianSessionTypedData(grant));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Sign a session revoke-all as readable `GuardianSessionRevokeAll` typed data. */
|
|
115
|
+
async signSessionRevokeAll(signerCommitment: string, timestampMs: number): Promise<string> {
|
|
116
|
+
return this.signTypedData(guardianSessionRevokeAllTypedData(signerCommitment, timestampMs));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async signTypedData(data: GuardianTypedData): Promise<string> {
|
|
103
120
|
const result = await this.provider.request({
|
|
104
121
|
method: 'eth_signTypedData_v4',
|
|
105
122
|
params: [this.address, JSON.stringify(data)],
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { RequestAuthPayload } from '@openzeppelin/guardian-client';
|
|
1
|
+
import type { RequestAuthPayload, SessionGrantFields } from '@openzeppelin/guardian-client';
|
|
2
2
|
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 { bytesToHex, normalizeHexWord } from '../utils/encoding.js';
|
|
6
6
|
import { lookupAuthDigest } from '../lookupAuth.js';
|
|
7
|
+
import { sessionGrantDigest, sessionRevokeAllDigest } from '../session/grant.js';
|
|
7
8
|
import { tryComputeEcdsaCommitmentHex } from '../utils/signature.js';
|
|
8
9
|
import { wordToBytes } from '../utils/word.js';
|
|
9
10
|
|
|
@@ -128,6 +129,14 @@ export class MidenWalletSigner implements Signer {
|
|
|
128
129
|
return candidate;
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The delegate `localAuthSigner` signs request auth, session grants and
|
|
134
|
+
* revoke-all, so Guardian must read its signatures in its format.
|
|
135
|
+
*/
|
|
136
|
+
get requestAuthFormat(): 'eip712' | undefined {
|
|
137
|
+
return this.localAuthSigner?.requestAuthFormat;
|
|
138
|
+
}
|
|
139
|
+
|
|
131
140
|
async signAccountIdWithTimestamp(accountId: string, timestamp: number): Promise<string> {
|
|
132
141
|
if (this.localAuthSigner) {
|
|
133
142
|
return this.localAuthSigner.signAccountIdWithTimestamp(accountId, timestamp);
|
|
@@ -164,6 +173,25 @@ export class MidenWalletSigner implements Signer {
|
|
|
164
173
|
return this.signWord(digest);
|
|
165
174
|
}
|
|
166
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Sign a session grant. Follows the same signer as request
|
|
178
|
+
* authentication, so the session acts for the key Guardian already knows.
|
|
179
|
+
*/
|
|
180
|
+
async signSessionGrant(grant: SessionGrantFields): Promise<string> {
|
|
181
|
+
if (this.localAuthSigner?.signSessionGrant) {
|
|
182
|
+
return this.localAuthSigner.signSessionGrant(grant);
|
|
183
|
+
}
|
|
184
|
+
return this.signWord(sessionGrantDigest(grant));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Sign a session revoke-all with the same signer as request authentication. */
|
|
188
|
+
async signSessionRevokeAll(signerCommitment: string, timestampMs: number): Promise<string> {
|
|
189
|
+
if (this.localAuthSigner?.signSessionRevokeAll) {
|
|
190
|
+
return this.localAuthSigner.signSessionRevokeAll(signerCommitment, timestampMs);
|
|
191
|
+
}
|
|
192
|
+
return this.signWord(sessionRevokeAllDigest(signerCommitment, timestampMs));
|
|
193
|
+
}
|
|
194
|
+
|
|
167
195
|
private async signWord(word: { toFelts: () => Array<{ asInt: () => bigint }> }): Promise<string> {
|
|
168
196
|
const bytes = wordToBytes(word);
|
|
169
197
|
const signatureBytes = await this.wallet.signBytes(bytes, 'word');
|
package/src/signers/para.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { RequestAuthPayload } from '@openzeppelin/guardian-client';
|
|
1
|
+
import type { RequestAuthPayload, SessionGrantFields } from '@openzeppelin/guardian-client';
|
|
2
2
|
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
6
|
import { lookupAuthDigest } from '../lookupAuth.js';
|
|
7
|
+
import { sessionGrantDigest, sessionRevokeAllDigest } from '../session/grant.js';
|
|
7
8
|
import { wordToBytes } from '../utils/word.js';
|
|
8
9
|
|
|
9
10
|
export interface ParaSigningContext {
|
|
@@ -69,6 +70,16 @@ export class ParaSigner implements Signer {
|
|
|
69
70
|
return this.signWord(digest);
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** Sign a session grant as its raw RPO digest. */
|
|
74
|
+
async signSessionGrant(grant: SessionGrantFields): Promise<string> {
|
|
75
|
+
return this.signWord(sessionGrantDigest(grant));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Sign a session revoke-all as its raw RPO digest. */
|
|
79
|
+
async signSessionRevokeAll(signerCommitment: string, timestampMs: number): Promise<string> {
|
|
80
|
+
return this.signWord(sessionRevokeAllDigest(signerCommitment, timestampMs));
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
private async signWord(word: { toHex: () => string; toFelts: () => Array<{ asInt: () => bigint }> }): Promise<string> {
|
|
73
84
|
const messageBase64 = uint8ArrayToBase64(
|
|
74
85
|
hexToBytes(EcdsaFormat.keccakDigestHex(wordToBytes(word))),
|
|
@@ -36,8 +36,8 @@ export interface MultisigRequestDraft {
|
|
|
36
36
|
* The salt is `options.salt` or a fresh one. `approvalExpirationDelta` left
|
|
37
37
|
* out means the approval never expires, the upstream default. `boundBlockNum`
|
|
38
38
|
* left out binds the store's sync height, which is what a proposer wants; a
|
|
39
|
-
* rebuild pins the proposal's
|
|
40
|
-
* already binds.
|
|
39
|
+
* rebuild pins the block the proposal's summary binds and the expiration the
|
|
40
|
+
* summary already binds.
|
|
41
41
|
*
|
|
42
42
|
* The salt is moved across the WASM boundary, so a handle is built here from
|
|
43
43
|
* the hex rather than taken from the caller, and nothing frees it afterwards.
|