@openzeppelin/guardian-client 0.19.0-rc.1 → 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.
Files changed (42) hide show
  1. package/README.md +61 -4
  2. package/dist/conversion.d.ts +8 -2
  3. package/dist/conversion.d.ts.map +1 -1
  4. package/dist/conversion.js +34 -1
  5. package/dist/conversion.js.map +1 -1
  6. package/dist/error-codes.d.ts +1 -1
  7. package/dist/error-codes.d.ts.map +1 -1
  8. package/dist/error-codes.js +2 -0
  9. package/dist/error-codes.js.map +1 -1
  10. package/dist/http.d.ts +24 -1
  11. package/dist/http.d.ts.map +1 -1
  12. package/dist/http.js +74 -20
  13. package/dist/http.js.map +1 -1
  14. package/dist/index.d.ts +2 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/request-envelope.d.ts +2 -2
  19. package/dist/request-envelope.d.ts.map +1 -1
  20. package/dist/request-envelope.js +2 -2
  21. package/dist/request-envelope.js.map +1 -1
  22. package/dist/server-types.d.ts +13 -0
  23. package/dist/server-types.d.ts.map +1 -1
  24. package/dist/session-request.d.ts +104 -0
  25. package/dist/session-request.d.ts.map +1 -0
  26. package/dist/session-request.js +164 -0
  27. package/dist/session-request.js.map +1 -0
  28. package/dist/types.d.ts +76 -4
  29. package/dist/types.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/conversion.test.ts +17 -0
  32. package/src/conversion.ts +42 -1
  33. package/src/error-codes.ts +2 -0
  34. package/src/http.test.ts +328 -11
  35. package/src/http.ts +97 -23
  36. package/src/index.ts +16 -0
  37. package/src/request-envelope.test.ts +4 -4
  38. package/src/request-envelope.ts +2 -2
  39. package/src/server-types.ts +15 -0
  40. package/src/session-request.test.ts +178 -0
  41. package/src/session-request.ts +191 -0
  42. package/src/types.ts +79 -3
@@ -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,3 +1,4 @@
1
+ import type { SessionOperation, WriteSequencer } from './session-request.js';
1
2
  import type { TransactionRequestEnvelope } from './request-envelope.js';
2
3
 
3
4
  import type { RequestAuthPayload } from './auth-request.js';
@@ -95,7 +96,7 @@ export type SessionEndReason = 'logout' | 'expired' | 'revoked' | 'rejected';
95
96
 
96
97
  /**
97
98
  * Signs session-eligible requests on behalf of a wallet after a session grant
98
- * was registered. Requests carry `x-auth-format: session`.
99
+ * was registered. Requests carry `x-auth-format: session-v2`.
99
100
  */
100
101
  export interface SessionRequestSigner {
101
102
  /** Hex SEC1-compressed P-256 session public key, sent as `x-pubkey`. */
@@ -107,7 +108,21 @@ export interface SessionRequestSigner {
107
108
  readonly signerCommitment: string;
108
109
  /** Unix seconds. */
109
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
+ */
110
124
  signRequest(
125
+ operation: SessionOperation,
111
126
  accountId: string,
112
127
  timestamp: number,
113
128
  requestPayload: RequestAuthPayload
@@ -128,14 +143,25 @@ export interface CreateSessionRequest {
128
143
  grant: SessionGrantFields;
129
144
  }
130
145
 
131
- export interface CreateSessionResponse {
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 {
132
158
  signerCommitment: string;
133
159
  /** RFC 3339 UTC. */
134
160
  expiresAt: string;
135
161
  }
136
162
 
137
163
  /** Session parameters a Guardian advertises on `GET /status`. */
138
- export interface SessionsStatus {
164
+ export interface SessionsStatus extends Partial<SessionRequestPolicy> {
139
165
  /** Grants may expire at most this many seconds after registration. */
140
166
  maxTtlSeconds: number;
141
167
  }
@@ -304,6 +330,47 @@ export interface StateObject {
304
330
  authScheme?: string;
305
331
  }
306
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
+
307
374
  /**
308
375
  * Head of the latest canonical state (`GET /state/nonce`): the account
309
376
  * nonce carried by the state GUARDIAN holds as canonical and that state's
@@ -361,6 +428,15 @@ export interface DeltaProposalRequest {
361
428
  metadata?: ProposalMetadata;
362
429
  /** The request a Guardian-executable proposal stores. Omit for a self-executed one. */
363
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;
364
440
  };
365
441
  }
366
442