@openzeppelin/guardian-client 0.18.3 → 0.19.0-rc.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 +127 -9
- package/dist/auth-request.d.ts +13 -0
- package/dist/auth-request.d.ts.map +1 -1
- package/dist/auth-request.js +26 -5
- package/dist/auth-request.js.map +1 -1
- package/dist/conversion.d.ts +8 -2
- package/dist/conversion.d.ts.map +1 -1
- package/dist/conversion.js +46 -3
- package/dist/conversion.js.map +1 -1
- package/dist/error-codes.d.ts +3 -5
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +23 -4
- package/dist/error-codes.js.map +1 -1
- package/dist/execution.d.ts +99 -0
- package/dist/execution.d.ts.map +1 -0
- package/dist/execution.js +124 -0
- package/dist/execution.js.map +1 -0
- package/dist/http.d.ts +81 -5
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +179 -27
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/request-envelope.d.ts +22 -0
- package/dist/request-envelope.d.ts.map +1 -0
- package/dist/request-envelope.js +51 -0
- package/dist/request-envelope.js.map +1 -0
- package/dist/server-types.d.ts +33 -0
- package/dist/server-types.d.ts.map +1 -1
- package/dist/session-request.d.ts +104 -0
- package/dist/session-request.d.ts.map +1 -0
- package/dist/session-request.js +164 -0
- package/dist/session-request.js.map +1 -0
- package/dist/types.d.ts +124 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/auth-request.test.ts +12 -0
- package/src/auth-request.ts +37 -5
- package/src/conversion.test.ts +98 -0
- package/src/conversion.ts +59 -3
- package/src/dependencies.test.ts +25 -0
- package/src/error-codes.test.ts +14 -1
- package/src/error-codes.ts +23 -4
- package/src/execution.test.ts +145 -0
- package/src/execution.ts +206 -0
- package/src/http.test.ts +752 -19
- package/src/http.ts +244 -30
- package/src/index.ts +50 -1
- package/src/request-envelope.test.ts +30 -0
- package/src/request-envelope.ts +68 -0
- package/src/server-types.ts +35 -1
- package/src/session-request.test.ts +178 -0
- package/src/session-request.ts +191 -0
- package/src/types.ts +129 -4
package/src/server-types.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import type { TransactionRequestEnvelope } from './request-envelope.js';
|
|
2
|
+
import type { ServerExecutionCapabilityWire } from './execution.js';
|
|
3
|
+
|
|
1
4
|
export interface ServerFalconSignature {
|
|
2
5
|
scheme: 'falcon';
|
|
3
6
|
signature: string;
|
|
@@ -43,6 +46,11 @@ export interface ServerProposalMetadata {
|
|
|
43
46
|
target_threshold?: number;
|
|
44
47
|
required_signatures?: number;
|
|
45
48
|
signer_commitments?: string[];
|
|
49
|
+
/**
|
|
50
|
+
* Target signer schemes (`"falcon"` / `"ecdsa"`), index-aligned with
|
|
51
|
+
* `signer_commitments` (issue #563). Proposer-supplied and stored as sent.
|
|
52
|
+
*/
|
|
53
|
+
signer_schemes?: string[];
|
|
46
54
|
target_procedure?: string;
|
|
47
55
|
salt?: string;
|
|
48
56
|
description?: string;
|
|
@@ -78,6 +86,7 @@ export interface ServerDeltaObject {
|
|
|
78
86
|
data?: string;
|
|
79
87
|
signatures?: Array<{ signer_id: string; signature: ServerProposalSignature }>;
|
|
80
88
|
metadata?: ServerProposalMetadata;
|
|
89
|
+
transaction_request?: TransactionRequestEnvelope;
|
|
81
90
|
};
|
|
82
91
|
ack_sig?: string;
|
|
83
92
|
ack_pubkey?: string;
|
|
@@ -113,6 +122,18 @@ export interface ServerStateObject {
|
|
|
113
122
|
auth_scheme?: string;
|
|
114
123
|
}
|
|
115
124
|
|
|
125
|
+
export interface ServerQueuedCandidate {
|
|
126
|
+
nonce: number;
|
|
127
|
+
prev_commitment: string;
|
|
128
|
+
new_commitment: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ServerStateTail {
|
|
132
|
+
state: ServerStateObject;
|
|
133
|
+
queue: ServerQueuedCandidate[];
|
|
134
|
+
accepts_submissions: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
116
137
|
export interface ServerCanonicalNonceResponse {
|
|
117
138
|
account_id: string;
|
|
118
139
|
nonce: number;
|
|
@@ -121,7 +142,13 @@ export interface ServerCanonicalNonceResponse {
|
|
|
121
142
|
|
|
122
143
|
export type ServerAuthConfig =
|
|
123
144
|
| { MidenFalconRpo: { cosigner_commitments: string[] } }
|
|
124
|
-
| { MidenEcdsa: { cosigner_commitments: string[] } }
|
|
145
|
+
| { MidenEcdsa: { cosigner_commitments: string[] } }
|
|
146
|
+
| {
|
|
147
|
+
Miden: {
|
|
148
|
+
cosigners: { commitment: string; scheme: 'falcon' | 'ecdsa' }[];
|
|
149
|
+
guardian_scheme: 'falcon' | 'ecdsa';
|
|
150
|
+
};
|
|
151
|
+
};
|
|
125
152
|
|
|
126
153
|
export interface ServerConfigureRequest {
|
|
127
154
|
account_id: string;
|
|
@@ -143,6 +170,8 @@ export interface ServerDeltaProposalRequest {
|
|
|
143
170
|
tx_summary: { data: string };
|
|
144
171
|
signatures: Array<{ signer_id: string; signature: ServerProposalSignature }>;
|
|
145
172
|
metadata?: ServerProposalMetadata;
|
|
173
|
+
transaction_request?: TransactionRequestEnvelope;
|
|
174
|
+
base_commitment?: string;
|
|
146
175
|
};
|
|
147
176
|
}
|
|
148
177
|
|
|
@@ -158,6 +187,7 @@ export interface ServerProposalsResponse {
|
|
|
158
187
|
export interface ServerAbandonCandidateRequest {
|
|
159
188
|
account_id: string;
|
|
160
189
|
nonce: number;
|
|
190
|
+
proposal_id: string;
|
|
161
191
|
}
|
|
162
192
|
|
|
163
193
|
export interface ServerAbandonCandidateResponse {
|
|
@@ -165,6 +195,7 @@ export interface ServerAbandonCandidateResponse {
|
|
|
165
195
|
nonce: number;
|
|
166
196
|
state: 'pending' | 'abandoned' | 'retained';
|
|
167
197
|
abandon_requested_at?: string;
|
|
198
|
+
commitment: string;
|
|
168
199
|
}
|
|
169
200
|
|
|
170
201
|
export interface ServerSignProposalRequest {
|
|
@@ -185,8 +216,10 @@ export interface ServerStatusResponse {
|
|
|
185
216
|
environment: string;
|
|
186
217
|
started_at: string;
|
|
187
218
|
uptime_seconds: number;
|
|
219
|
+
execution?: ServerExecutionCapabilityWire | null;
|
|
188
220
|
sessions?: {
|
|
189
221
|
max_ttl_seconds: number;
|
|
222
|
+
request_auth_version?: number;
|
|
190
223
|
};
|
|
191
224
|
}
|
|
192
225
|
|
|
@@ -209,6 +242,7 @@ export interface ServerCreateSessionRequest {
|
|
|
209
242
|
export interface ServerCreateSessionResponse {
|
|
210
243
|
signer_commitment: string;
|
|
211
244
|
expires_at: string;
|
|
245
|
+
request_auth_version?: number;
|
|
212
246
|
}
|
|
213
247
|
|
|
214
248
|
export interface ServerRevokeSessionResponse {
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
SESSION_TIMESTAMP_MAX_AHEAD_MS,
|
|
5
|
+
SessionTimestamps,
|
|
6
|
+
WriteSequencer,
|
|
7
|
+
isSessionWrite,
|
|
8
|
+
SESSION_REQUEST_AUTH_FORMAT,
|
|
9
|
+
SESSION_REQUEST_VERSION,
|
|
10
|
+
SessionOperation,
|
|
11
|
+
sessionRequestEnvelope,
|
|
12
|
+
type SessionTransport,
|
|
13
|
+
} from './session-request.js';
|
|
14
|
+
|
|
15
|
+
interface RequestVector {
|
|
16
|
+
name: string;
|
|
17
|
+
transport: number;
|
|
18
|
+
operation: number;
|
|
19
|
+
request_word_hex: string;
|
|
20
|
+
envelope_hex: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Fixture {
|
|
24
|
+
auth_format: string;
|
|
25
|
+
version: number;
|
|
26
|
+
operations: Array<{ id: number; name: string; replay_policy: string }>;
|
|
27
|
+
vectors: RequestVector[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const fixture = JSON.parse(
|
|
31
|
+
readFileSync(
|
|
32
|
+
new URL('../../../crates/shared/tests/fixtures/session_request_vectors.json', import.meta.url),
|
|
33
|
+
'utf8'
|
|
34
|
+
)
|
|
35
|
+
) as Fixture;
|
|
36
|
+
|
|
37
|
+
const fromHex = (hex: string): Uint8Array =>
|
|
38
|
+
Uint8Array.from(Buffer.from(hex.replace(/^0x/, ''), 'hex'));
|
|
39
|
+
|
|
40
|
+
describe('session request envelope', () => {
|
|
41
|
+
it('matches the Rust format, version and operation registry', () => {
|
|
42
|
+
expect(SESSION_REQUEST_AUTH_FORMAT).toBe(fixture.auth_format);
|
|
43
|
+
expect(SESSION_REQUEST_VERSION).toBe(fixture.version);
|
|
44
|
+
expect(
|
|
45
|
+
Object.entries(SessionOperation).map(([name, id]) => ({ id, name }))
|
|
46
|
+
).toEqual(fixture.operations.map(({ id, name }) => ({ id, name })));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('classifies writes as the Rust registry does', () => {
|
|
50
|
+
for (const { id, name, replay_policy } of fixture.operations) {
|
|
51
|
+
expect(isSessionWrite(id as SessionOperation), name).toBe(replay_policy === 'write');
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('encodes every vector byte for byte', () => {
|
|
56
|
+
for (const vector of fixture.vectors) {
|
|
57
|
+
const envelope = sessionRequestEnvelope(
|
|
58
|
+
vector.transport as SessionTransport,
|
|
59
|
+
vector.operation as SessionOperation,
|
|
60
|
+
fromHex(vector.request_word_hex)
|
|
61
|
+
);
|
|
62
|
+
expect(Buffer.from(envelope).toString('hex'), vector.name).toBe(
|
|
63
|
+
vector.envelope_hex.replace(/^0x/, '')
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('refuses a request word of the wrong length', () => {
|
|
69
|
+
expect(() =>
|
|
70
|
+
sessionRequestEnvelope(1, SessionOperation.GetState, new Uint8Array(31))
|
|
71
|
+
).toThrow();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('SessionTimestamps', () => {
|
|
76
|
+
const NOW = 1_800_000_000_000;
|
|
77
|
+
|
|
78
|
+
it('gives equal clock readings distinct timestamps', async () => {
|
|
79
|
+
const timestamps = new SessionTimestamps(() => NOW);
|
|
80
|
+
expect(await timestamps.next('0xa')).toBe(NOW);
|
|
81
|
+
expect(await timestamps.next('0xa')).toBe(NOW + 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('keeps accounts independent', async () => {
|
|
85
|
+
const timestamps = new SessionTimestamps(() => NOW);
|
|
86
|
+
for (let i = 0; i < 500; i++) {
|
|
87
|
+
await timestamps.next('0xa');
|
|
88
|
+
}
|
|
89
|
+
expect(await timestamps.next('0xb')).toBe(NOW);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('waits instead of running more than a second ahead of the clock', async () => {
|
|
93
|
+
let now = NOW;
|
|
94
|
+
const timestamps = new SessionTimestamps(() => now);
|
|
95
|
+
for (let i = 0; i <= SESSION_TIMESTAMP_MAX_AHEAD_MS; i++) {
|
|
96
|
+
await timestamps.next('0xa');
|
|
97
|
+
}
|
|
98
|
+
const pending = timestamps.next('0xa');
|
|
99
|
+
now = NOW + 1;
|
|
100
|
+
expect(await pending).toBe(NOW + SESSION_TIMESTAMP_MAX_AHEAD_MS + 1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('never gives two waiting callers the same timestamp', async () => {
|
|
104
|
+
let now = NOW;
|
|
105
|
+
const timestamps = new SessionTimestamps(() => now);
|
|
106
|
+
for (let i = 0; i <= SESSION_TIMESTAMP_MAX_AHEAD_MS; i++) {
|
|
107
|
+
await timestamps.next('0xa');
|
|
108
|
+
}
|
|
109
|
+
const waiting = Array.from({ length: 50 }, () => timestamps.next('0xa'));
|
|
110
|
+
const ticker = setInterval(() => {
|
|
111
|
+
now += 1;
|
|
112
|
+
}, 1);
|
|
113
|
+
const issued = await Promise.all(waiting);
|
|
114
|
+
clearInterval(ticker);
|
|
115
|
+
expect(new Set(issued).size).toBe(issued.length);
|
|
116
|
+
for (const timestamp of issued) {
|
|
117
|
+
expect(timestamp).toBeGreaterThan(NOW + SESSION_TIMESTAMP_MAX_AHEAD_MS);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('restarts from the clock when it steps back instead of waiting', async () => {
|
|
122
|
+
let now = NOW;
|
|
123
|
+
const timestamps = new SessionTimestamps(() => now);
|
|
124
|
+
expect(await timestamps.next('0xa')).toBe(NOW);
|
|
125
|
+
now = NOW - 10 * 60 * 1000;
|
|
126
|
+
expect(await timestamps.next('0xa')).toBe(now);
|
|
127
|
+
expect(await timestamps.next('0xa')).toBe(now + 1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('jumps near the ahead limit after a replay rejection', async () => {
|
|
131
|
+
const timestamps = new SessionTimestamps(() => NOW);
|
|
132
|
+
expect(await timestamps.next('0xa')).toBe(NOW);
|
|
133
|
+
const retried = await timestamps.next('0xa', true);
|
|
134
|
+
expect(retried).toBeGreaterThan(NOW + 1_000 - 200 - 1);
|
|
135
|
+
expect(retried).toBeLessThanOrEqual(NOW + 1_000);
|
|
136
|
+
expect(await timestamps.next('0xb')).toBe(NOW);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('WriteSequencer', () => {
|
|
141
|
+
it('runs writes to one account one at a time and keeps going after a failure', async () => {
|
|
142
|
+
const sequencer = new WriteSequencer();
|
|
143
|
+
const order: string[] = [];
|
|
144
|
+
let releaseFirst!: () => void;
|
|
145
|
+
const first = sequencer.run('0xa', async () => {
|
|
146
|
+
order.push('first start');
|
|
147
|
+
await new Promise<void>((resolve) => (releaseFirst = resolve));
|
|
148
|
+
order.push('first end');
|
|
149
|
+
throw new Error('first failed');
|
|
150
|
+
});
|
|
151
|
+
const second = sequencer.run('0xa', async () => {
|
|
152
|
+
order.push('second');
|
|
153
|
+
return 2;
|
|
154
|
+
});
|
|
155
|
+
const otherAccount = sequencer.run('0xb', async () => {
|
|
156
|
+
order.push('other account');
|
|
157
|
+
return 3;
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(await otherAccount).toBe(3);
|
|
161
|
+
expect(order).toEqual(['first start', 'other account']);
|
|
162
|
+
releaseFirst();
|
|
163
|
+
await expect(first).rejects.toThrow('first failed');
|
|
164
|
+
expect(await second).toBe(2);
|
|
165
|
+
expect(order).toEqual(['first start', 'other account', 'first end', 'second']);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('classifies only the four write operations as writes', () => {
|
|
169
|
+
expect(
|
|
170
|
+
Object.values(SessionOperation).filter((operation) => isSessionWrite(operation))
|
|
171
|
+
).toEqual([
|
|
172
|
+
SessionOperation.PushDelta,
|
|
173
|
+
SessionOperation.PushDeltaProposal,
|
|
174
|
+
SessionOperation.SignDeltaProposal,
|
|
175
|
+
SessionOperation.ExecuteDeltaProposal,
|
|
176
|
+
]);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version 2 session request envelope (mirror of
|
|
3
|
+
* `crates/shared/src/session_request.rs`). A session key signs a fixed
|
|
4
|
+
* 62-byte envelope that binds the transport and the operation in addition to
|
|
5
|
+
* the account, timestamp and payload of the `AuthRequestMessage` word, so a
|
|
6
|
+
* request signed for one route is never accepted on another:
|
|
7
|
+
*
|
|
8
|
+
* `"GUARDIAN_SESSION_REQUEST" 0x00 || u16be(2) || u8(transport) ||
|
|
9
|
+
* u16be(operation) || request word (32 bytes)`
|
|
10
|
+
*
|
|
11
|
+
* P-256 signs these bytes with SHA-256: WebCrypto
|
|
12
|
+
* `sign({ name: 'ECDSA', hash: 'SHA-256' }, key, envelope)`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** `x-auth-format` value of a version 2 session request. */
|
|
16
|
+
export const SESSION_REQUEST_AUTH_FORMAT = 'session-v2';
|
|
17
|
+
|
|
18
|
+
/** The envelope version this client signs. */
|
|
19
|
+
export const SESSION_REQUEST_VERSION = 2;
|
|
20
|
+
|
|
21
|
+
/** Length of an encoded envelope. */
|
|
22
|
+
export const SESSION_REQUEST_ENVELOPE_LENGTH = 62;
|
|
23
|
+
|
|
24
|
+
const DOMAIN = new TextEncoder().encode('GUARDIAN_SESSION_REQUEST\0');
|
|
25
|
+
|
|
26
|
+
/** How a request reaches Guardian. This client always uses HTTP. */
|
|
27
|
+
export const SessionTransport = {
|
|
28
|
+
Http: 1,
|
|
29
|
+
Grpc: 2,
|
|
30
|
+
} as const;
|
|
31
|
+
export type SessionTransport = (typeof SessionTransport)[keyof typeof SessionTransport];
|
|
32
|
+
|
|
33
|
+
/** Every per-account operation a session may sign. Ids are permanent. */
|
|
34
|
+
export const SessionOperation = {
|
|
35
|
+
GetState: 1,
|
|
36
|
+
GetCanonicalNonce: 2,
|
|
37
|
+
GetDelta: 3,
|
|
38
|
+
GetDeltaSince: 4,
|
|
39
|
+
GetDeltaHistory: 5,
|
|
40
|
+
GetDeltaProposals: 6,
|
|
41
|
+
GetDeltaProposal: 7,
|
|
42
|
+
PushDelta: 8,
|
|
43
|
+
PushDeltaProposal: 9,
|
|
44
|
+
SignDeltaProposal: 10,
|
|
45
|
+
ExecuteDeltaProposal: 11,
|
|
46
|
+
GetDeltaProposalExecution: 12,
|
|
47
|
+
GetCurrentExecution: 13,
|
|
48
|
+
GetStateTail: 14,
|
|
49
|
+
} as const;
|
|
50
|
+
export type SessionOperation = (typeof SessionOperation)[keyof typeof SessionOperation];
|
|
51
|
+
|
|
52
|
+
const WRITE_OPERATIONS: ReadonlySet<SessionOperation> = new Set([
|
|
53
|
+
SessionOperation.PushDelta,
|
|
54
|
+
SessionOperation.PushDeltaProposal,
|
|
55
|
+
SessionOperation.SignDeltaProposal,
|
|
56
|
+
SessionOperation.ExecuteDeltaProposal,
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Whether Guardian checks `operation` under the strict write floor. */
|
|
60
|
+
export function isSessionWrite(operation: SessionOperation): boolean {
|
|
61
|
+
return WRITE_OPERATIONS.has(operation);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The bytes a session key signs for one per-account request. */
|
|
65
|
+
export function sessionRequestEnvelope(
|
|
66
|
+
transport: SessionTransport,
|
|
67
|
+
operation: SessionOperation,
|
|
68
|
+
requestWord: Uint8Array
|
|
69
|
+
): Uint8Array<ArrayBuffer> {
|
|
70
|
+
if (requestWord.length !== 32) {
|
|
71
|
+
throw new Error(`Request word must be 32 bytes, got ${requestWord.length}`);
|
|
72
|
+
}
|
|
73
|
+
const envelope = new Uint8Array(SESSION_REQUEST_ENVELOPE_LENGTH);
|
|
74
|
+
envelope.set(DOMAIN, 0);
|
|
75
|
+
envelope[25] = (SESSION_REQUEST_VERSION >> 8) & 0xff;
|
|
76
|
+
envelope[26] = SESSION_REQUEST_VERSION & 0xff;
|
|
77
|
+
envelope[27] = transport;
|
|
78
|
+
envelope[28] = (operation >> 8) & 0xff;
|
|
79
|
+
envelope[29] = operation & 0xff;
|
|
80
|
+
envelope.set(requestWord, 30);
|
|
81
|
+
return envelope;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Guardian does not support the session request format this client signs.
|
|
86
|
+
* Terminal: the session is kept, nothing is retried and the wallet does not
|
|
87
|
+
* sign instead.
|
|
88
|
+
*/
|
|
89
|
+
export class UnsupportedAuthVersionError extends Error {
|
|
90
|
+
constructor(message: string) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = 'UnsupportedAuthVersionError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Throws {@link UnsupportedAuthVersionError} unless a Guardian reports the
|
|
98
|
+
* session request version this client signs: such a server would refuse
|
|
99
|
+
* every session request.
|
|
100
|
+
*/
|
|
101
|
+
export function assertSupportedSessionPolicy(policy: { requestAuthVersion?: number }): void {
|
|
102
|
+
if (policy.requestAuthVersion !== SESSION_REQUEST_VERSION) {
|
|
103
|
+
throw new UnsupportedAuthVersionError(
|
|
104
|
+
`Guardian reports session request version ${policy.requestAuthVersion ?? 'none'}, ` +
|
|
105
|
+
`this client signs version ${SESSION_REQUEST_VERSION}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** How far a session request timestamp may run ahead of the local clock. */
|
|
111
|
+
export const SESSION_TIMESTAMP_MAX_AHEAD_MS = 1_000;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A next timestamp this far ahead of the local clock means the clock stepped
|
|
115
|
+
* back, so minting restarts from the clock instead of waiting the step out.
|
|
116
|
+
*/
|
|
117
|
+
export const SESSION_CLOCK_STEP_BACK_MS = 5_000;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* After a replay rejection the next timestamp jumps to within this much below
|
|
121
|
+
* the ahead limit, at random, so it clears a floor an earlier page or tab of
|
|
122
|
+
* the same session left, and two of them stop colliding.
|
|
123
|
+
*/
|
|
124
|
+
export const SESSION_REPLAY_JUMP_SPREAD_MS = 200;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Strictly increasing request timestamps per account, shared by every client
|
|
128
|
+
* using one session, so two otherwise identical requests are never the same
|
|
129
|
+
* signed bytes. Accounts are independent: a burst on one does not push
|
|
130
|
+
* another's timestamps ahead of the clock.
|
|
131
|
+
*/
|
|
132
|
+
export class SessionTimestamps {
|
|
133
|
+
private readonly last = new Map<string, number>();
|
|
134
|
+
|
|
135
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The next timestamp for `accountId`, waiting while it would run more than a
|
|
139
|
+
* second ahead. `afterReplay` jumps it near that limit first (see
|
|
140
|
+
* `SESSION_REPLAY_JUMP_SPREAD_MS`).
|
|
141
|
+
*/
|
|
142
|
+
async next(accountId: string, afterReplay = false): Promise<number> {
|
|
143
|
+
if (afterReplay) {
|
|
144
|
+
const jump =
|
|
145
|
+
this.now() +
|
|
146
|
+
SESSION_TIMESTAMP_MAX_AHEAD_MS -
|
|
147
|
+
Math.floor(Math.random() * SESSION_REPLAY_JUMP_SPREAD_MS);
|
|
148
|
+
this.last.set(accountId, Math.max(this.last.get(accountId) ?? -Infinity, jump - 1));
|
|
149
|
+
}
|
|
150
|
+
for (;;) {
|
|
151
|
+
const now = this.now();
|
|
152
|
+
let last = this.last.get(accountId) ?? -Infinity;
|
|
153
|
+
if (last - now > SESSION_CLOCK_STEP_BACK_MS) {
|
|
154
|
+
last = now - 1;
|
|
155
|
+
}
|
|
156
|
+
const next = Math.max(now, last + 1);
|
|
157
|
+
const ahead = next - now;
|
|
158
|
+
if (ahead <= SESSION_TIMESTAMP_MAX_AHEAD_MS) {
|
|
159
|
+
this.last.set(accountId, next);
|
|
160
|
+
return next;
|
|
161
|
+
}
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, ahead - SESSION_TIMESTAMP_MAX_AHEAD_MS));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Runs a session's writes to one account one at a time, from minting the
|
|
169
|
+
* timestamp through the last replay retry, so they reach Guardian in
|
|
170
|
+
* timestamp order and never lose the strict write floor to each other. Writes
|
|
171
|
+
* to other accounts and all reads run freely.
|
|
172
|
+
*/
|
|
173
|
+
export class WriteSequencer {
|
|
174
|
+
private readonly tails = new Map<string, Promise<unknown>>();
|
|
175
|
+
|
|
176
|
+
run<T>(accountId: string, write: () => Promise<T>): Promise<T> {
|
|
177
|
+
const previous = this.tails.get(accountId) ?? Promise.resolve();
|
|
178
|
+
const result = previous.then(write);
|
|
179
|
+
const tail = result.then(
|
|
180
|
+
() => undefined,
|
|
181
|
+
() => undefined,
|
|
182
|
+
);
|
|
183
|
+
this.tails.set(accountId, tail);
|
|
184
|
+
void tail.then(() => {
|
|
185
|
+
if (this.tails.get(accountId) === tail) {
|
|
186
|
+
this.tails.delete(accountId);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import type { SessionOperation, WriteSequencer } from './session-request.js';
|
|
2
|
+
import type { TransactionRequestEnvelope } from './request-envelope.js';
|
|
3
|
+
|
|
1
4
|
import type { RequestAuthPayload } from './auth-request.js';
|
|
5
|
+
import type { ServerExecutionCapability } from './execution.js';
|
|
2
6
|
|
|
3
7
|
export interface Signer {
|
|
4
8
|
readonly commitment: string;
|
|
@@ -92,7 +96,7 @@ export type SessionEndReason = 'logout' | 'expired' | 'revoked' | 'rejected';
|
|
|
92
96
|
|
|
93
97
|
/**
|
|
94
98
|
* Signs session-eligible requests on behalf of a wallet after a session grant
|
|
95
|
-
* was registered. Requests carry `x-auth-format: session`.
|
|
99
|
+
* was registered. Requests carry `x-auth-format: session-v2`.
|
|
96
100
|
*/
|
|
97
101
|
export interface SessionRequestSigner {
|
|
98
102
|
/** Hex SEC1-compressed P-256 session public key, sent as `x-pubkey`. */
|
|
@@ -104,7 +108,21 @@ export interface SessionRequestSigner {
|
|
|
104
108
|
readonly signerCommitment: string;
|
|
105
109
|
/** Unix seconds. */
|
|
106
110
|
readonly expiresAt: number;
|
|
111
|
+
/**
|
|
112
|
+
* The next request timestamp (Unix ms) for `accountId`: strictly increasing
|
|
113
|
+
* per account for this session, never more than a second ahead of the
|
|
114
|
+
* clock, and jumped near that limit when `afterReplay` (see
|
|
115
|
+
* `SessionTimestamps`).
|
|
116
|
+
*/
|
|
117
|
+
nextTimestamp(accountId: string, afterReplay: boolean): Promise<number>;
|
|
118
|
+
/** Orders this session's writes per account; shared by every client using it. */
|
|
119
|
+
readonly writeSequencer: WriteSequencer;
|
|
120
|
+
/**
|
|
121
|
+
* Sign the version 2 envelope (see `sessionRequestEnvelope`) of this
|
|
122
|
+
* request over HTTP as `operation`.
|
|
123
|
+
*/
|
|
107
124
|
signRequest(
|
|
125
|
+
operation: SessionOperation,
|
|
108
126
|
accountId: string,
|
|
109
127
|
timestamp: number,
|
|
110
128
|
requestPayload: RequestAuthPayload
|
|
@@ -125,14 +143,25 @@ export interface CreateSessionRequest {
|
|
|
125
143
|
grant: SessionGrantFields;
|
|
126
144
|
}
|
|
127
145
|
|
|
128
|
-
|
|
146
|
+
/** How a Guardian expects session requests to be signed. */
|
|
147
|
+
export interface SessionRequestPolicy {
|
|
148
|
+
/**
|
|
149
|
+
* The session request envelope version; this client signs 2, under which
|
|
150
|
+
* each signed session read is admitted once, within 60 s of server time,
|
|
151
|
+
* except the canonical nonce poll, accepted every time within 60 s behind
|
|
152
|
+
* and 30 s ahead.
|
|
153
|
+
*/
|
|
154
|
+
requestAuthVersion: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface CreateSessionResponse extends SessionRequestPolicy {
|
|
129
158
|
signerCommitment: string;
|
|
130
159
|
/** RFC 3339 UTC. */
|
|
131
160
|
expiresAt: string;
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
/** Session parameters a Guardian advertises on `GET /status`. */
|
|
135
|
-
export interface SessionsStatus {
|
|
164
|
+
export interface SessionsStatus extends Partial<SessionRequestPolicy> {
|
|
136
165
|
/** Grants may expire at most this many seconds after registration. */
|
|
137
166
|
maxTtlSeconds: number;
|
|
138
167
|
}
|
|
@@ -153,6 +182,12 @@ export type ProposalSignature = FalconSignature | EcdsaSignature;
|
|
|
153
182
|
|
|
154
183
|
export type SignatureScheme = 'falcon' | 'ecdsa';
|
|
155
184
|
|
|
185
|
+
/** One cosigner of a {@link AuthConfig} `Miden` auth and the scheme Guardian verifies its signatures under. */
|
|
186
|
+
export interface MidenCosignerConfig {
|
|
187
|
+
commitment: string;
|
|
188
|
+
scheme: SignatureScheme;
|
|
189
|
+
}
|
|
190
|
+
|
|
156
191
|
export type AuthConfig =
|
|
157
192
|
| {
|
|
158
193
|
MidenFalconRpo: {
|
|
@@ -163,6 +198,18 @@ export type AuthConfig =
|
|
|
163
198
|
MidenEcdsa: {
|
|
164
199
|
cosigner_commitments: string[];
|
|
165
200
|
};
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Cosigners that each carry their own scheme, and the GUARDIAN's scheme
|
|
204
|
+
* (issue #563). Send it only for an account whose cosigners and GUARDIAN
|
|
205
|
+
* do not all share one scheme; servers before #563 reject it, and the
|
|
206
|
+
* single-scheme variants keep serving the accounts that do.
|
|
207
|
+
*/
|
|
208
|
+
| {
|
|
209
|
+
Miden: {
|
|
210
|
+
cosigners: MidenCosignerConfig[];
|
|
211
|
+
guardian_scheme: SignatureScheme;
|
|
212
|
+
};
|
|
166
213
|
};
|
|
167
214
|
|
|
168
215
|
export interface CosignerSignature {
|
|
@@ -206,6 +253,15 @@ export interface ProposalMetadata {
|
|
|
206
253
|
targetThreshold?: number;
|
|
207
254
|
requiredSignatures?: number;
|
|
208
255
|
signerCommitments?: string[];
|
|
256
|
+
/**
|
|
257
|
+
* Target signer schemes (`"falcon"` / `"ecdsa"`), index-aligned with
|
|
258
|
+
* `signerCommitments`, for an update-signers proposal whose signers do not
|
|
259
|
+
* all share one scheme (issue #563). Absent => every target signer keeps the
|
|
260
|
+
* proposer's scheme. GUARDIAN stores the proposer's values as sent, so a
|
|
261
|
+
* consumer validates them, as it validates the rest of the metadata against
|
|
262
|
+
* the transaction summary, before relying on them.
|
|
263
|
+
*/
|
|
264
|
+
signerSchemes?: string[];
|
|
209
265
|
targetProcedure?: string;
|
|
210
266
|
salt?: string;
|
|
211
267
|
description?: string;
|
|
@@ -245,6 +301,8 @@ export interface DeltaObject {
|
|
|
245
301
|
txSummary: { data: string };
|
|
246
302
|
signatures: Array<{ signerId: string; signature: ProposalSignature }>;
|
|
247
303
|
metadata?: ProposalMetadata;
|
|
304
|
+
/** The request a Guardian-executable proposal stores. Absent on a self-executed one. */
|
|
305
|
+
transactionRequest?: TransactionRequestEnvelope;
|
|
248
306
|
};
|
|
249
307
|
ackSig?: string;
|
|
250
308
|
ackPubkey?: string;
|
|
@@ -268,9 +326,51 @@ export interface StateObject {
|
|
|
268
326
|
stateJson: { data: string };
|
|
269
327
|
createdAt: string;
|
|
270
328
|
updatedAt: string;
|
|
329
|
+
/** `falcon` or `ecdsa` when the account's cosigners and GUARDIAN share that scheme, `mixed` when they do not. */
|
|
271
330
|
authScheme?: string;
|
|
272
331
|
}
|
|
273
332
|
|
|
333
|
+
/** A queued candidate the queue tail replays (`GET /state/tail`). */
|
|
334
|
+
export interface QueuedCandidate {
|
|
335
|
+
/** The account nonce the candidate's transaction leaves the account at. */
|
|
336
|
+
nonce: number;
|
|
337
|
+
/** The state it builds on: the canonical state for the oldest, the previous candidate's `newCommitment` after it. */
|
|
338
|
+
prevCommitment: string;
|
|
339
|
+
/** The state it leaves the account at. */
|
|
340
|
+
newCommitment: string;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* The queue tail (`GET /state/tail`, issue #517): the state a new delta or
|
|
345
|
+
* proposal must build on while the account's candidate queue holds
|
|
346
|
+
* candidates, and the candidates that lead to it.
|
|
347
|
+
*/
|
|
348
|
+
export interface StateTail {
|
|
349
|
+
/**
|
|
350
|
+
* The canonical state with every queued candidate applied, shaped as
|
|
351
|
+
* `getState` returns a state; the canonical state itself when nothing is
|
|
352
|
+
* queued. Not on chain until the queued candidates land.
|
|
353
|
+
*/
|
|
354
|
+
state: StateObject;
|
|
355
|
+
/**
|
|
356
|
+
* The queued candidates, oldest first: the first builds on the canonical
|
|
357
|
+
* state, each later one on the one before it, and the last one's
|
|
358
|
+
* `newCommitment` is the tail's commitment. Empty when nothing is queued.
|
|
359
|
+
*/
|
|
360
|
+
queue: QueuedCandidate[];
|
|
361
|
+
/**
|
|
362
|
+
* Whether the queue takes a delta or proposal built on this tail now.
|
|
363
|
+
* False while the queue is full (at the default depth of 1, whenever
|
|
364
|
+
* anything is queued), while the tail changes the account's signers or
|
|
365
|
+
* guardian key, or while its newest candidate was queued by a server before
|
|
366
|
+
* 0.19.0 under a label other than its nonce: a submission on the tail then
|
|
367
|
+
* gets `conflict_pending_delta`, so wait for the queue to drain and read
|
|
368
|
+
* again. True holds only at this read: the tail can move before the push,
|
|
369
|
+
* and the account's status and the proposal limits still apply.
|
|
370
|
+
*/
|
|
371
|
+
acceptsSubmissions: boolean;
|
|
372
|
+
}
|
|
373
|
+
|
|
274
374
|
/**
|
|
275
375
|
* Head of the latest canonical state (`GET /state/nonce`): the account
|
|
276
376
|
* nonce carried by the state GUARDIAN holds as canonical and that state's
|
|
@@ -310,6 +410,11 @@ export interface StatusResponse {
|
|
|
310
410
|
environment: string;
|
|
311
411
|
startedAt: string;
|
|
312
412
|
uptimeSeconds: number;
|
|
413
|
+
/**
|
|
414
|
+
* Whether the server accepts Guardian execution requests. Absent on a Guardian that predates
|
|
415
|
+
* Guardian execution: it does not offer it.
|
|
416
|
+
*/
|
|
417
|
+
execution?: ServerExecutionCapability;
|
|
313
418
|
/** Present on every Guardian that accepts session grants; absent on older servers. */
|
|
314
419
|
sessions?: SessionsStatus;
|
|
315
420
|
}
|
|
@@ -321,6 +426,17 @@ export interface DeltaProposalRequest {
|
|
|
321
426
|
txSummary: { data: string };
|
|
322
427
|
signatures: Array<{ signerId: string; signature: ProposalSignature }>;
|
|
323
428
|
metadata?: ProposalMetadata;
|
|
429
|
+
/** The request a Guardian-executable proposal stores. Omit for a self-executed one. */
|
|
430
|
+
transactionRequest?: TransactionRequestEnvelope;
|
|
431
|
+
/**
|
|
432
|
+
* Commitment of the state the summary was built on (issue #517). GUARDIAN
|
|
433
|
+
* 0.19.0 and later record the proposal only when it is the queue tail,
|
|
434
|
+
* refusing a base another queued candidate claimed with
|
|
435
|
+
* `conflict_pending_delta` and an unknown one with `commitment_mismatch`.
|
|
436
|
+
* Omit it to be judged by the nonce alone; earlier servers store it with
|
|
437
|
+
* the payload and ignore it.
|
|
438
|
+
*/
|
|
439
|
+
baseCommitment?: string;
|
|
324
440
|
};
|
|
325
441
|
}
|
|
326
442
|
|
|
@@ -357,13 +473,22 @@ export interface AbandonCandidateResponse {
|
|
|
357
473
|
* return the original timestamp; absent once resolved.
|
|
358
474
|
*/
|
|
359
475
|
abandonRequestedAt?: string;
|
|
476
|
+
/**
|
|
477
|
+
* The abandoned delta's `new_commitment`. Another delta can later take
|
|
478
|
+
* the same nonce (a Guardian execution archives the abandoned one), so
|
|
479
|
+
* pass this to `abandonStatus` to poll for this delta.
|
|
480
|
+
*/
|
|
481
|
+
commitment: string;
|
|
360
482
|
}
|
|
361
483
|
|
|
362
484
|
/**
|
|
363
485
|
* Resolution of an abandon request, as observed via the delta feed.
|
|
364
486
|
* `'retained'` means the account slot is released but the on-chain
|
|
365
487
|
* outcome is still uncertain — "unlocked but unresolved", never to be
|
|
366
|
-
* read as "the transaction did not land".
|
|
488
|
+
* read as "the transaction did not land". When another delta has taken
|
|
489
|
+
* the nonce, `'abandoned'` means it is canonical (the abandoned
|
|
490
|
+
* transaction can no longer land) and `'waiting'` that it is still a
|
|
491
|
+
* candidate.
|
|
367
492
|
*/
|
|
368
493
|
export type AbandonStatus = 'waiting' | 'landed' | 'abandoned' | 'retained' | 'unexpected';
|
|
369
494
|
|