@oleary-labs/signet-sdk 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,210 @@
1
+ # @oleary-labs/signet-sdk
2
+
3
+ TypeScript client for the [Signet](https://github.com/oleary-labs/signet-protocol) threshold-signing protocol. Handles session keys, OAuth + ZK auth, threshold keygen, threshold signing across three signing schemes (FROST/secp256k1, FROST/Ed25519, threshold ECDSA/secp256k1), delegation tokens, scoped sub-keys, ERC-4337 user operations, and x402 payments.
4
+
5
+ Extracted from `signet-ui`; now consumed by `signet-ui` and `signet-better-mcp`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ bun add @oleary-labs/signet-sdk
11
+ # or: npm install @oleary-labs/signet-sdk
12
+ ```
13
+
14
+ Optional peer dependencies (only needed if you call the corresponding subpaths):
15
+
16
+ | Peer dep | Required for | Default state |
17
+ |---|---|---|
18
+ | `viem` >=2.0.0 | `./userop`, `./bundler`, `./x402`, anything that touches EVM ABI encoding | strongly recommended for most consumers |
19
+ | `@noir-lang/noir_js` 1.0.0-beta.11 | `./proof`, `./witness` (in-browser ZK proving) | optional — only needed if you prove client-side |
20
+ | `@aztec/bb.js` 0.82.2 | `./proof`, `./witness` | optional — same as above |
21
+ | `@oleary-labs/signet-circuits` ^0.3.0 | `./proof`, `./witness` | optional — server-side proving via `signet-min-bundler`'s `POST /v1/prove` doesn't require this peer at all |
22
+
23
+ Most consumers delegate ZK proof generation to `signet-min-bundler`'s `POST /v1/prove` (see `./server-prover`) and don't install the Noir/bb peers at all.
24
+
25
+ ## Concept overview — what's where
26
+
27
+ Signet's protocol nodes accept a `curve` parameter on every signing endpoint and dispatch to one of three schemes:
28
+
29
+ | Curve string | Scheme | Where it's used |
30
+ |---|---|---|
31
+ | `frost_secp256k1` | FROST Schnorr / secp256k1 (RFC 9591) | Admin signing, ERC-4337 user-op signing, anything FROST-based |
32
+ | `frost_ed25519` | FROST Schnorr / Ed25519 | Solana and other Ed25519 chains |
33
+ | `ecdsa_secp256k1` | Threshold ECDSA / secp256k1 (DJNPO20, 4-round robust) | Scoped sub-keys, x402 agent payments, EIP-712 typed-data signing where the verifier expects raw ECDSA |
34
+
35
+ The SDK reflects this with a unified function-per-operation API rather than separate client classes — every signing function takes a `curve` argument (or hard-codes one where it makes sense). The HTTP responses for signing carry both `signature` (the scheme's native format) and `ecdsa_signature` (a 65-byte r‖s‖v variant) where applicable.
36
+
37
+ Session authentication and auth-key certificates always use ephemeral local ECDSA — that's universal and lives entirely in the client.
38
+
39
+ ## Subpath exports
40
+
41
+ The SDK ships with 19 subpath exports. Import the one you need; the entry point (`@oleary-labs/signet-sdk`) re-exports the most common identifiers.
42
+
43
+ ### Core
44
+
45
+ | Subpath | Purpose |
46
+ |---|---|
47
+ | `./session` | Generate/import an ephemeral secp256k1 session keypair; hex helpers |
48
+ | `./types` | `SessionKeypair`, `IdTokenClaims`, and shared types |
49
+ | `./request` | Build canonical request hashes; sign them with the session ECDSA key (used internally by most other modules) |
50
+
51
+ ### Auth
52
+
53
+ | Subpath | Purpose |
54
+ |---|---|
55
+ | `./oauth` | OAuth code exchange and JWT extraction (`handleOAuthCallback`, `getOAuthReturnTo`) |
56
+ | `./jwt` | JWT decode / validate |
57
+ | `./jwks` | JWKS fetch and key lookup |
58
+ | `./bootstrap` | Bootstrap-group authentication wrapper |
59
+ | `./authkey-session` | Auth-key certificate session — server-side flow that lets a backend authenticate with a long-lived ECDSA key instead of an OAuth bearer token |
60
+
61
+ ### Keygen and signing
62
+
63
+ | Subpath | Purpose |
64
+ |---|---|
65
+ | `./keygen` | Threshold keygen request (`keygen(config, keypair, claims, keySuffix?, identity?, curve?, scope?)`) |
66
+ | `./admin` | Admin API auth — bootstrap-group FROST signing for admin endpoints |
67
+ | `./delegate` | Mint and redeem delegation JWTs (`requestDelegation`, `authenticateWithDelegation`) for autonomous-agent flows |
68
+ | `./scopedSign` | EIP-712 structured signing with scoped sub-keys (`signTypedData(...)`; `buildEIP712ScopeForTypedData` / `buildEIP712Scope` / `eip712TypeHash`; `CHAIN_PRESETS`) |
69
+ | `./frostVerify` | Client-side FROST Schnorr verification (RFC 9591) — useful for tests and round-trip checks |
70
+
71
+ ### ERC-4337 and payments
72
+
73
+ | Subpath | Purpose |
74
+ |---|---|
75
+ | `./userop` | Build ERC-4337 v0.7 user operations and FROST-sign them |
76
+ | `./bundler` | JSON-RPC client for `signet-min-bundler` (send/estimate/receipt) |
77
+ | `./x402` | `x402Fetch` — performs the full x402 dance (request → 402 → sign → retry) |
78
+
79
+ ### ZK proofs
80
+
81
+ | Subpath | Purpose |
82
+ |---|---|
83
+ | `./witness` | Build the witness for the `jwt_auth` Noir circuit |
84
+ | `./proof` | Generate the proof in-browser using `@aztec/bb.js` + `@noir-lang/noir_js` |
85
+ | `./server-prover` | Delegate proof generation to `signet-min-bundler`'s `POST /v1/prove` (recommended for most apps) |
86
+
87
+ ## The `curve` parameter contract
88
+
89
+ Functions that hit a signing endpoint accept a curve string. Pass one of the three canonical values exactly:
90
+
91
+ ```ts
92
+ "frost_secp256k1" | "frost_ed25519" | "ecdsa_secp256k1"
93
+ ```
94
+
95
+ Mismatches between what you pass here and the key's actual scheme will fail at the node, not in the client.
96
+
97
+ The session itself (the ephemeral keypair from `./session`) and auth-key certificates always use local ECDSA — there's no curve parameter for those; only the threshold-signing operations take one.
98
+
99
+ For the canonical per-curve reference (algorithms, storage prefixes, response shapes, picking guidance), see [`signet-protocol/docs/CURVES.md`](https://github.com/oleary-labs/signet-protocol/blob/main/docs/CURVES.md).
100
+
101
+ ## Example flows
102
+
103
+ ### A. FROST keygen via OAuth (Console main flow)
104
+
105
+ ```ts
106
+ import { generateSessionKeypair } from "@oleary-labs/signet-sdk/session";
107
+ import { handleOAuthCallback } from "@oleary-labs/signet-sdk/oauth";
108
+ import { keygen } from "@oleary-labs/signet-sdk/keygen";
109
+
110
+ const keypair = await generateSessionKeypair();
111
+ const { claims } = await handleOAuthCallback(/* ... */);
112
+
113
+ const result = await keygen(
114
+ {
115
+ nodeUrls: ["https://node-1.example.com", "https://node-2.example.com", "https://node-3.example.com"],
116
+ groupId: "0xf0700...",
117
+ proxyEndpoint: "/api/node/proxy", // optional CORS proxy
118
+ },
119
+ keypair,
120
+ claims,
121
+ /* keySuffix */ undefined,
122
+ /* identity */ undefined,
123
+ /* curve */ "frost_secp256k1",
124
+ );
125
+
126
+ console.log(result.ethereumAddress, result.groupPublicKey);
127
+ ```
128
+
129
+ ### B. ECDSA scoped sub-key + EIP-712 signing (x402 agent flow)
130
+
131
+ ```ts
132
+ import { keygen } from "@oleary-labs/signet-sdk/keygen";
133
+ import { requestDelegation, authenticateWithDelegation } from "@oleary-labs/signet-sdk/delegate";
134
+ import { signTypedData, buildEIP712ScopeForTypedData, CHAIN_PRESETS } from "@oleary-labs/signet-sdk/scopedSign";
135
+ import { buildTransferAuthorization, x402Fetch } from "@oleary-labs/signet-sdk/x402";
136
+
137
+ // Operator side: mint a scoped sub-key bound to USDC on Base AND the
138
+ // TransferWithAuthorization method. A 0x03 scope binds chainId +
139
+ // verifyingContract + the EIP-712 primary type, so the key cannot sign a
140
+ // different method (e.g. an EIP-2612 permit) on the same token. Derive the
141
+ // scope from a sample typed-data payload (message values are irrelevant):
142
+ const preset = CHAIN_PRESETS[0]; // USDC on Base
143
+ const zero = "0x0000000000000000000000000000000000000000";
144
+ const sample = buildTransferAuthorization(
145
+ zero, zero, "0", preset.verifyingContract, preset.chainId, preset.eip712Name, preset.eip712Version,
146
+ );
147
+ const scope = buildEIP712ScopeForTypedData(sample);
148
+ const subkey = await keygen(config, keypair, claims, /* keySuffix */ "agent-1", /* identity */ undefined, "ecdsa_secp256k1", scope);
149
+
150
+ // Mint a delegation JWT for the agent
151
+ const delegation = await requestDelegation({ /* ... */ curve: "ecdsa_secp256k1" });
152
+
153
+ // Agent side: redeem the delegation and sign
154
+ const agentSession = await authenticateWithDelegation(delegation, /* ... */);
155
+ const signed = await signTypedData(
156
+ nodeUrl,
157
+ proxyEndpoint,
158
+ groupId,
159
+ subkey.keyId,
160
+ "ecdsa_secp256k1",
161
+ typedData,
162
+ agentSession.keypair,
163
+ agentSession.claims,
164
+ );
165
+
166
+ // Use it for an x402 invoice
167
+ const response = await x402Fetch("https://api.example.com/pay", { /* ... */ });
168
+ ```
169
+
170
+ ### C. Admin signing via bootstrap group (FROST)
171
+
172
+ ```ts
173
+ import { adminSign } from "@oleary-labs/signet-sdk/admin";
174
+
175
+ const adminSig = await adminSign({
176
+ groupId,
177
+ nodeUrl,
178
+ proxyEndpoint,
179
+ // ...
180
+ });
181
+ ```
182
+
183
+ See `signet-ui` (Console, x402 demo) and `signet-better-mcp` (MCP server) for full working examples.
184
+
185
+ ## Relation to other Signet repos
186
+
187
+ ```
188
+ signet-circuits ──► signet-protocol (embeds VK)
189
+ ──► signet-min-bundler (embeds circuit, runs `nargo` + `bb`)
190
+ ──► signet-sdk (peer dep, optional — for browser proving)
191
+
192
+ signet-protocol ◄── HTTP /v1/* ── signet-sdk ◄── signet-ui, signet-better-mcp
193
+ signet-min-bundler ◄── /v1/prove ── signet-sdk (./server-prover)
194
+ ```
195
+
196
+ The SDK has no opinion about hosting — caller supplies all URLs at runtime (no hardcoded endpoints). For local development point it at the signet-protocol devnet (`http://localhost:8080..8082`) and `signet-min-bundler` (`http://localhost:4337`).
197
+
198
+ ## Build
199
+
200
+ ```bash
201
+ bun install
202
+ bun run build # tsc to ./dist (ESM + .d.ts)
203
+ bun run typecheck # tsc --noEmit
204
+ ```
205
+
206
+ ESM-only. `prepublishOnly` runs the build so `bun publish` (or `npm publish`) ships a current `dist/`.
207
+
208
+ ## License
209
+
210
+ MIT.
@@ -5,6 +5,7 @@
5
5
  * structured payload that the node verifies against the key's scope
6
6
  * before computing the hash and signing.
7
7
  */
8
+ import type { Hex } from "viem";
8
9
  import type { SessionKeypair, IdTokenClaims } from "./types";
9
10
  export interface EIP712Domain {
10
11
  name?: string;
@@ -26,13 +27,35 @@ export interface ScopedSignResult {
26
27
  ecdsaSignature: string;
27
28
  curve: string;
28
29
  }
30
+ type EIP712Types = Record<string, ReadonlyArray<{
31
+ name: string;
32
+ type: string;
33
+ }>>;
29
34
  /**
30
- * Build an EIP-712 domain scope (scheme 0x03).
35
+ * EIP-712 typeHash: keccak256(encodeType(primaryType)). This is the value a
36
+ * 0x03 scope binds, and the same value the verifying contract uses — so a
37
+ * key scoped to one method (e.g. TransferWithAuthorization) cannot sign a
38
+ * different method on the same contract (e.g. permit).
39
+ */
40
+ export declare function eip712TypeHash(primaryType: string, types: EIP712Types): Hex;
41
+ /**
42
+ * Build an EIP-712 domain+type scope (scheme 0x03).
31
43
  *
32
44
  * Format: 0x03 | chainId (8 bytes, uint64 BE) | verifyingContract (20 bytes)
33
- * Total: 29 bytes.
45
+ * | typeHash (32 bytes). Total: 61 bytes.
46
+ *
47
+ * `typeHash` is keccak256(encodeType(primaryType)) — see {@link eip712TypeHash}.
48
+ * Binding the type (not just the domain) prevents a key authorized for one
49
+ * typed-data method from signing a different method on the same contract.
50
+ */
51
+ export declare function buildEIP712Scope(chainId: number, verifyingContract: string, typeHash: Hex): string;
52
+ /**
53
+ * Convenience: build a 0x03 scope directly from an EIP-712 typed-data sample,
54
+ * deriving chainId, verifyingContract, and typeHash from it. Any sample with
55
+ * the intended domain + primary type works (message values are irrelevant to
56
+ * the scope). This is the recommended way to scope a key for a given method.
34
57
  */
35
- export declare function buildEIP712Scope(chainId: number, verifyingContract: string): string;
58
+ export declare function buildEIP712ScopeForTypedData(typedData: Pick<EIP712TypedData, "domain" | "types" | "primaryType">): string;
36
59
  /**
37
60
  * Sign a structured EIP-712 payload with a scoped key.
38
61
  *
@@ -79,4 +102,5 @@ export declare const CHAIN_PRESETS: readonly [{
79
102
  readonly eip712Name: "USD Coin";
80
103
  readonly eip712Version: "2";
81
104
  }];
105
+ export {};
82
106
  //# sourceMappingURL=scopedSign.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"scopedSign.d.ts","sourceRoot":"","sources":["../src/scopedSign.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAO7D,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IAC7D,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACf;AAMD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,GAAG,MAAM,CAiBnF;AAMD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,eAAe,EAC1B,cAAc,EAAE,cAAc,EAC9B,MAAM,EAAE,aAAa,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,gBAAgB,CAAC,CAkD3B;AAMD,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiChB,CAAC"}
1
+ {"version":3,"file":"scopedSign.d.ts","sourceRoot":"","sources":["../src/scopedSign.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,MAAM,CAAC;AAChC,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAQ7D,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IAC7D,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAClC;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;CACf;AAMD,KAAK,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAAC;AAyBjF;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,GAAG,CAK3E;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,MAAM,EACf,iBAAiB,EAAE,MAAM,EACzB,QAAQ,EAAE,GAAG,GACZ,MAAM,CA2BR;AAED;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC1C,SAAS,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,OAAO,GAAG,aAAa,CAAC,GACnE,MAAM,CAOR;AAMD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,eAAe,EAC1B,cAAc,EAAE,cAAc,EAC9B,MAAM,EAAE,aAAa,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,gBAAgB,CAAC,CA0D3B;AAMD,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiChB,CAAC"}
@@ -5,18 +5,60 @@
5
5
  * structured payload that the node verifies against the key's scope
6
6
  * before computing the hash and signing.
7
7
  */
8
- import { signKeygenRequest } from "./request";
9
- // ---------------------------------------------------------------------------
10
- // Scope construction
11
- // ---------------------------------------------------------------------------
8
+ import { hashTypedData, keccak256, stringToBytes } from "viem";
9
+ import { signSignRequest } from "./request";
10
+ import { hexToBytes } from "./session";
11
+ /**
12
+ * Encode an EIP-712 type per the spec: the primary type's definition
13
+ * followed by its referenced struct types in alphabetical order, e.g.
14
+ * `TransferWithAuthorization(address from,address to,uint256 value,...)`.
15
+ * Matches go-ethereum's `apitypes.TypeHash` encoding used by the node, so
16
+ * the resulting typeHash byte-matches what the node verifies.
17
+ */
18
+ function encodeEIP712Type(primaryType, types) {
19
+ const deps = new Set();
20
+ const visit = (t) => {
21
+ const base = t.replace(/(\[\d*\])+$/, ""); // strip array suffixes
22
+ if (!types[base] || deps.has(base))
23
+ return;
24
+ deps.add(base);
25
+ for (const f of types[base])
26
+ visit(f.type);
27
+ };
28
+ for (const f of types[primaryType] ?? [])
29
+ visit(f.type);
30
+ const sorted = [...deps].filter((d) => d !== primaryType).sort();
31
+ const encodeOne = (t) => `${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`;
32
+ return [primaryType, ...sorted].map(encodeOne).join("");
33
+ }
34
+ /**
35
+ * EIP-712 typeHash: keccak256(encodeType(primaryType)). This is the value a
36
+ * 0x03 scope binds, and the same value the verifying contract uses — so a
37
+ * key scoped to one method (e.g. TransferWithAuthorization) cannot sign a
38
+ * different method on the same contract (e.g. permit).
39
+ */
40
+ export function eip712TypeHash(primaryType, types) {
41
+ if (!types[primaryType]) {
42
+ throw new Error(`primaryType "${primaryType}" not declared in types`);
43
+ }
44
+ return keccak256(stringToBytes(encodeEIP712Type(primaryType, types)));
45
+ }
12
46
  /**
13
- * Build an EIP-712 domain scope (scheme 0x03).
47
+ * Build an EIP-712 domain+type scope (scheme 0x03).
14
48
  *
15
49
  * Format: 0x03 | chainId (8 bytes, uint64 BE) | verifyingContract (20 bytes)
16
- * Total: 29 bytes.
50
+ * | typeHash (32 bytes). Total: 61 bytes.
51
+ *
52
+ * `typeHash` is keccak256(encodeType(primaryType)) — see {@link eip712TypeHash}.
53
+ * Binding the type (not just the domain) prevents a key authorized for one
54
+ * typed-data method from signing a different method on the same contract.
17
55
  */
18
- export function buildEIP712Scope(chainId, verifyingContract) {
19
- const buf = new Uint8Array(29);
56
+ export function buildEIP712Scope(chainId, verifyingContract, typeHash) {
57
+ const th = typeHash.startsWith("0x") ? typeHash.slice(2) : typeHash;
58
+ if (th.length !== 64) {
59
+ throw new Error(`typeHash must be 32 bytes (64 hex chars), got ${th.length}`);
60
+ }
61
+ const buf = new Uint8Array(61);
20
62
  buf[0] = 0x03;
21
63
  // chainId as 8-byte big-endian
22
64
  const view = new DataView(buf.buffer);
@@ -28,8 +70,22 @@ export function buildEIP712Scope(chainId, verifyingContract) {
28
70
  for (let i = 0; i < 20; i++) {
29
71
  buf[9 + i] = parseInt(addr.slice(i * 2, i * 2 + 2), 16);
30
72
  }
73
+ // typeHash as 32 bytes
74
+ for (let i = 0; i < 32; i++) {
75
+ buf[29 + i] = parseInt(th.slice(i * 2, i * 2 + 2), 16);
76
+ }
31
77
  return "0x" + Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
32
78
  }
79
+ /**
80
+ * Convenience: build a 0x03 scope directly from an EIP-712 typed-data sample,
81
+ * deriving chainId, verifyingContract, and typeHash from it. Any sample with
82
+ * the intended domain + primary type works (message values are irrelevant to
83
+ * the scope). This is the recommended way to scope a key for a given method.
84
+ */
85
+ export function buildEIP712ScopeForTypedData(typedData) {
86
+ const typeHash = eip712TypeHash(typedData.primaryType, typedData.types);
87
+ return buildEIP712Scope(typedData.domain.chainId, typedData.domain.verifyingContract, typeHash);
88
+ }
33
89
  // ---------------------------------------------------------------------------
34
90
  // Structured signing
35
91
  // ---------------------------------------------------------------------------
@@ -50,13 +106,17 @@ export function buildEIP712Scope(chainId, verifyingContract) {
50
106
  * @param identity - For auth key cert sessions
51
107
  */
52
108
  export async function signTypedData(nodeUrl, proxyEndpoint, groupId, keyId, curve, typedData, sessionKeypair, claims, identity) {
53
- // Build session-authenticated request (no message hash payload is sent separately)
54
- // The canonical hash must use the full sub-key ID (identity + suffix).
109
+ // The canonical request hash must use the full sub-key ID (identity + suffix).
55
110
  // Extract suffix from keyId: "oauth:iss:sub:suffix" → suffix is last segment
56
111
  // The identity param is "iss:sub", so we need to add the suffix.
57
112
  const keyParts = keyId.split(":");
58
113
  const keySuffix = keyParts.length > 1 ? keyParts[keyParts.length - 1] : undefined;
59
- const signReq = await signKeygenRequest(sessionKeypair, claims, groupId, keySuffix, identity);
114
+ // Compute the EIP-712 hash locally and bind it into the session request
115
+ // signature. The nodes recompute hashTypedData from the payload and verify
116
+ // the request signature against it — without this binding, a malicious
117
+ // initiator could substitute any payload matching the key's scope.
118
+ const payloadHash = hexToBytes(hashTypedData(typedData).slice(2));
119
+ const signReq = await signSignRequest(sessionKeypair, claims, groupId, payloadHash, keySuffix, identity);
60
120
  const res = await fetch(proxyEndpoint, {
61
121
  method: "POST",
62
122
  headers: {
@@ -1 +1 @@
1
- {"version":3,"file":"scopedSign.js","sourceRoot":"","sources":["../src/scopedSign.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"scopedSign.js","sourceRoot":"","sources":["../src/scopedSign.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,MAAM,CAAC;AAG/D,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AAgCvC;;;;;;GAMG;AACH,SAAS,gBAAgB,CAAC,WAAmB,EAAE,KAAkB;IAC/D,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAC;IAC/B,MAAM,KAAK,GAAG,CAAC,CAAS,EAAE,EAAE;QAC1B,MAAM,IAAI,GAAG,CAAC,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC,CAAC,uBAAuB;QAClE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,OAAO;QAC3C,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACf,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC;YAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAC7C,CAAC,CAAC;IACF,KAAK,MAAM,CAAC,IAAI,KAAK,CAAC,WAAW,CAAC,IAAI,EAAE;QAAE,KAAK,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC;IAExD,MAAM,MAAM,GAAG,CAAC,GAAG,IAAI,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,WAAW,CAAC,CAAC,IAAI,EAAE,CAAC;IACjE,MAAM,SAAS,GAAG,CAAC,CAAS,EAAE,EAAE,CAC9B,GAAG,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC;IAClE,OAAO,CAAC,WAAW,EAAE,GAAG,MAAM,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AAC1D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,WAAmB,EAAE,KAAkB;IACpE,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,EAAE,CAAC;QACxB,MAAM,IAAI,KAAK,CAAC,gBAAgB,WAAW,yBAAyB,CAAC,CAAC;IACxE,CAAC;IACD,OAAO,SAAS,CAAC,aAAa,CAAC,gBAAgB,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC;AACxE,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,gBAAgB,CAC9B,OAAe,EACf,iBAAyB,EACzB,QAAa;IAEb,MAAM,EAAE,GAAG,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;IACpE,IAAI,EAAE,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QACrB,MAAM,IAAI,KAAK,CAAC,iDAAiD,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC;IAChF,CAAC;IAED,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;IAC/B,GAAG,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC;IAEd,+BAA+B;IAC/B,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACtC,IAAI,CAAC,YAAY,CAAC,CAAC,EAAE,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC;IAEtC,gCAAgC;IAChC,MAAM,IAAI,GAAG,iBAAiB,CAAC,UAAU,CAAC,IAAI,CAAC;QAC7C,CAAC,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC;QAC5B,CAAC,CAAC,iBAAiB,CAAC;IACtB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5B,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IAC1D,CAAC;IAED,uBAAuB;IACvB,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5B,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC,GAAG,QAAQ,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;IACzD,CAAC;IAED,OAAO,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;AACrF,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,4BAA4B,CAC1C,SAAoE;IAEpE,MAAM,QAAQ,GAAG,cAAc,CAAC,SAAS,CAAC,WAAW,EAAE,SAAS,CAAC,KAAK,CAAC,CAAC;IACxE,OAAO,gBAAgB,CACrB,SAAS,CAAC,MAAM,CAAC,OAAO,EACxB,SAAS,CAAC,MAAM,CAAC,iBAAiB,EAClC,QAAQ,CACT,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,qBAAqB;AACrB,8EAA8E;AAE9E;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,OAAe,EACf,aAAqB,EACrB,OAAe,EACf,KAAa,EACb,KAAa,EACb,SAA0B,EAC1B,cAA8B,EAC9B,MAAqB,EACrB,QAAiB;IAEjB,+EAA+E;IAC/E,6EAA6E;IAC7E,iEAAiE;IACjE,MAAM,QAAQ,GAAG,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAClC,MAAM,SAAS,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;IAElF,wEAAwE;IACxE,2EAA2E;IAC3E,uEAAuE;IACvE,mEAAmE;IACnE,MAAM,WAAW,GAAG,UAAU,CAC5B,aAAa,CAAC,SAAgD,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CACzE,CAAC;IAEF,MAAM,OAAO,GAAG,MAAM,eAAe,CACnC,cAAc,EACd,MAAM,EACN,OAAO,EACP,WAAW,EACX,SAAS,EACT,QAAQ,CACT,CAAC;IAEF,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,aAAa,EAAE;QACrC,MAAM,EAAE,MAAM;QACd,OAAO,EAAE;YACP,cAAc,EAAE,kBAAkB;YAClC,YAAY,EAAE,OAAO;YACrB,aAAa,EAAE,UAAU;SAC1B;QACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,QAAQ,EAAE,OAAO,CAAC,WAAW,EAAE;YAC/B,MAAM,EAAE,KAAK;YACb,UAAU,EAAE,SAAS;YACrB,KAAK;YACL,OAAO,EAAE;gBACP,MAAM,EAAE,QAAQ;gBAChB,UAAU,EAAE,SAAS;aACtB;YACD,WAAW,EAAE,OAAO,CAAC,WAAW;YAChC,WAAW,EAAE,OAAO,CAAC,WAAW;YAChC,KAAK,EAAE,OAAO,CAAC,KAAK;YACpB,SAAS,EAAE,OAAO,CAAC,SAAS;SAC7B,CAAC;KACH,CAAC,CAAC;IAEH,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;QACZ,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,uBAAuB,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,CAAC,CAAC;IACjE,CAAC;IAED,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAC9B,OAAO;QACL,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,cAAc,EAAE,IAAI,CAAC,eAAe;QACpC,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,KAAK;KAC3B,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,UAAU;AACV,8EAA8E;AAE9E,MAAM,CAAC,MAAM,aAAa,GAAG;IAC3B;QACE,KAAK,EAAE,cAAc;QACrB,OAAO,EAAE,IAAI;QACb,YAAY,EAAE,MAAM;QACpB,iBAAiB,EAAE,4CAA4C;QAC/D,UAAU,EAAE,UAAU;QACtB,aAAa,EAAE,GAAG;KACnB;IACD;QACE,KAAK,EAAE,sBAAsB;QAC7B,OAAO,EAAE,KAAK;QACd,YAAY,EAAE,MAAM;QACpB,iBAAiB,EAAE,4CAA4C;QAC/D,UAAU,EAAE,UAAU;QACtB,aAAa,EAAE,GAAG;KACnB;IACD;QACE,KAAK,EAAE,kBAAkB;QACzB,OAAO,EAAE,CAAC;QACV,YAAY,EAAE,MAAM;QACpB,iBAAiB,EAAE,4CAA4C;QAC/D,UAAU,EAAE,UAAU;QACtB,aAAa,EAAE,GAAG;KACnB;IACD;QACE,KAAK,EAAE,iBAAiB;QACxB,OAAO,EAAE,QAAQ;QACjB,YAAY,EAAE,MAAM;QACpB,iBAAiB,EAAE,4CAA4C;QAC/D,UAAU,EAAE,UAAU;QACtB,aAAa,EAAE,GAAG;KACnB;CACO,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oleary-labs/signet-sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Signet DKMS SDK — threshold signing, key management, delegation, and x402 payments",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -100,7 +100,7 @@
100
100
  "viem": ">=2.0.0",
101
101
  "@noir-lang/noir_js": "1.0.0-beta.11",
102
102
  "@aztec/bb.js": "0.82.2",
103
- "@oleary-labs/signet-circuits": "0.1.0"
103
+ "@oleary-labs/signet-circuits": "^0.3.0"
104
104
  },
105
105
  "peerDependenciesMeta": {
106
106
  "@noir-lang/noir_js": {
@@ -0,0 +1,97 @@
1
+ import { test, expect } from "bun:test";
2
+ import {
3
+ eip712TypeHash,
4
+ buildEIP712Scope,
5
+ buildEIP712ScopeForTypedData,
6
+ } from "./scopedSign";
7
+
8
+ // Canonical EIP-3009 TransferWithAuthorization typehash, as used by USDC and
9
+ // every EIP-712 verifier. If our encodeType/typeHash matches this, it matches
10
+ // both the on-chain contract and the node (go-ethereum apitypes.TypeHash).
11
+ const TWA_TYPEHASH =
12
+ "0x7c7c6cdb67a18743f49ec6fa9b35f50d52ed05cbed4cc592e13b44501c1a2267";
13
+
14
+ const TWA_TYPES = {
15
+ EIP712Domain: [
16
+ { name: "name", type: "string" },
17
+ { name: "version", type: "string" },
18
+ { name: "chainId", type: "uint256" },
19
+ { name: "verifyingContract", type: "address" },
20
+ ],
21
+ TransferWithAuthorization: [
22
+ { name: "from", type: "address" },
23
+ { name: "to", type: "address" },
24
+ { name: "value", type: "uint256" },
25
+ { name: "validAfter", type: "uint256" },
26
+ { name: "validBefore", type: "uint256" },
27
+ { name: "nonce", type: "bytes32" },
28
+ ],
29
+ };
30
+
31
+ test("eip712TypeHash matches the canonical EIP-3009 typehash", () => {
32
+ expect(eip712TypeHash("TransferWithAuthorization", TWA_TYPES)).toBe(TWA_TYPEHASH);
33
+ });
34
+
35
+ test("eip712TypeHash differs for a different method (permit)", () => {
36
+ const permitTypes = {
37
+ Permit: [
38
+ { name: "owner", type: "address" },
39
+ { name: "spender", type: "address" },
40
+ { name: "value", type: "uint256" },
41
+ { name: "nonce", type: "uint256" },
42
+ { name: "deadline", type: "uint256" },
43
+ ],
44
+ };
45
+ expect(eip712TypeHash("Permit", permitTypes)).not.toBe(TWA_TYPEHASH);
46
+ });
47
+
48
+ test("eip712TypeHash includes nested struct dependencies, sorted", () => {
49
+ // encodeType must append referenced structs alphabetically:
50
+ // "Mail(Person from,Person to)Person(address wallet)"
51
+ const types = {
52
+ Mail: [
53
+ { name: "from", type: "Person" },
54
+ { name: "to", type: "Person" },
55
+ ],
56
+ Person: [{ name: "wallet", type: "address" }],
57
+ };
58
+ // keccak256 of the expected encodeType string.
59
+ // (verified against viem hashStruct / go-ethereum elsewhere)
60
+ const expected =
61
+ eip712TypeHash("Mail", types);
62
+ // sanity: a layout change (drop nested field) yields a different hash.
63
+ const altered = {
64
+ Mail: [
65
+ { name: "from", type: "Person" },
66
+ { name: "to", type: "Person" },
67
+ ],
68
+ Person: [{ name: "name", type: "string" }],
69
+ };
70
+ expect(eip712TypeHash("Mail", altered)).not.toBe(expected);
71
+ });
72
+
73
+ test("buildEIP712Scope produces a 61-byte 0x03 scope with the typeHash", () => {
74
+ const contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
75
+ const scope = buildEIP712Scope(8453, contract, TWA_TYPEHASH);
76
+ const bytes = scope.slice(2);
77
+ expect(bytes.length).toBe(61 * 2);
78
+ expect(bytes.slice(0, 2)).toBe("03"); // scheme
79
+ // typeHash occupies the final 32 bytes.
80
+ expect("0x" + bytes.slice(29 * 2)).toBe(TWA_TYPEHASH);
81
+ });
82
+
83
+ test("buildEIP712Scope rejects a bad-length typeHash", () => {
84
+ expect(() => buildEIP712Scope(1, "0x" + "11".repeat(20), "0x1234")).toThrow();
85
+ });
86
+
87
+ test("buildEIP712ScopeForTypedData derives chain/contract/type from a sample", () => {
88
+ const contract = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
89
+ const td = {
90
+ domain: { name: "USD Coin", version: "2", chainId: 8453, verifyingContract: contract },
91
+ types: TWA_TYPES,
92
+ primaryType: "TransferWithAuthorization",
93
+ message: {},
94
+ };
95
+ const scope = buildEIP712ScopeForTypedData(td);
96
+ expect(scope).toBe(buildEIP712Scope(8453, contract, TWA_TYPEHASH));
97
+ });
package/src/scopedSign.ts CHANGED
@@ -6,8 +6,11 @@
6
6
  * before computing the hash and signing.
7
7
  */
8
8
 
9
+ import { hashTypedData, keccak256, stringToBytes } from "viem";
10
+ import type { Hex } from "viem";
9
11
  import type { SessionKeypair, IdTokenClaims } from "./types";
10
- import { signKeygenRequest } from "./request";
12
+ import { signSignRequest } from "./request";
13
+ import { hexToBytes } from "./session";
11
14
 
12
15
  // ---------------------------------------------------------------------------
13
16
  // Types
@@ -37,14 +40,65 @@ export interface ScopedSignResult {
37
40
  // Scope construction
38
41
  // ---------------------------------------------------------------------------
39
42
 
43
+ type EIP712Types = Record<string, ReadonlyArray<{ name: string; type: string }>>;
44
+
45
+ /**
46
+ * Encode an EIP-712 type per the spec: the primary type's definition
47
+ * followed by its referenced struct types in alphabetical order, e.g.
48
+ * `TransferWithAuthorization(address from,address to,uint256 value,...)`.
49
+ * Matches go-ethereum's `apitypes.TypeHash` encoding used by the node, so
50
+ * the resulting typeHash byte-matches what the node verifies.
51
+ */
52
+ function encodeEIP712Type(primaryType: string, types: EIP712Types): string {
53
+ const deps = new Set<string>();
54
+ const visit = (t: string) => {
55
+ const base = t.replace(/(\[\d*\])+$/, ""); // strip array suffixes
56
+ if (!types[base] || deps.has(base)) return;
57
+ deps.add(base);
58
+ for (const f of types[base]) visit(f.type);
59
+ };
60
+ for (const f of types[primaryType] ?? []) visit(f.type);
61
+
62
+ const sorted = [...deps].filter((d) => d !== primaryType).sort();
63
+ const encodeOne = (t: string) =>
64
+ `${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`;
65
+ return [primaryType, ...sorted].map(encodeOne).join("");
66
+ }
67
+
68
+ /**
69
+ * EIP-712 typeHash: keccak256(encodeType(primaryType)). This is the value a
70
+ * 0x03 scope binds, and the same value the verifying contract uses — so a
71
+ * key scoped to one method (e.g. TransferWithAuthorization) cannot sign a
72
+ * different method on the same contract (e.g. permit).
73
+ */
74
+ export function eip712TypeHash(primaryType: string, types: EIP712Types): Hex {
75
+ if (!types[primaryType]) {
76
+ throw new Error(`primaryType "${primaryType}" not declared in types`);
77
+ }
78
+ return keccak256(stringToBytes(encodeEIP712Type(primaryType, types)));
79
+ }
80
+
40
81
  /**
41
- * Build an EIP-712 domain scope (scheme 0x03).
82
+ * Build an EIP-712 domain+type scope (scheme 0x03).
42
83
  *
43
84
  * Format: 0x03 | chainId (8 bytes, uint64 BE) | verifyingContract (20 bytes)
44
- * Total: 29 bytes.
85
+ * | typeHash (32 bytes). Total: 61 bytes.
86
+ *
87
+ * `typeHash` is keccak256(encodeType(primaryType)) — see {@link eip712TypeHash}.
88
+ * Binding the type (not just the domain) prevents a key authorized for one
89
+ * typed-data method from signing a different method on the same contract.
45
90
  */
46
- export function buildEIP712Scope(chainId: number, verifyingContract: string): string {
47
- const buf = new Uint8Array(29);
91
+ export function buildEIP712Scope(
92
+ chainId: number,
93
+ verifyingContract: string,
94
+ typeHash: Hex,
95
+ ): string {
96
+ const th = typeHash.startsWith("0x") ? typeHash.slice(2) : typeHash;
97
+ if (th.length !== 64) {
98
+ throw new Error(`typeHash must be 32 bytes (64 hex chars), got ${th.length}`);
99
+ }
100
+
101
+ const buf = new Uint8Array(61);
48
102
  buf[0] = 0x03;
49
103
 
50
104
  // chainId as 8-byte big-endian
@@ -59,9 +113,31 @@ export function buildEIP712Scope(chainId: number, verifyingContract: string): st
59
113
  buf[9 + i] = parseInt(addr.slice(i * 2, i * 2 + 2), 16);
60
114
  }
61
115
 
116
+ // typeHash as 32 bytes
117
+ for (let i = 0; i < 32; i++) {
118
+ buf[29 + i] = parseInt(th.slice(i * 2, i * 2 + 2), 16);
119
+ }
120
+
62
121
  return "0x" + Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
63
122
  }
64
123
 
124
+ /**
125
+ * Convenience: build a 0x03 scope directly from an EIP-712 typed-data sample,
126
+ * deriving chainId, verifyingContract, and typeHash from it. Any sample with
127
+ * the intended domain + primary type works (message values are irrelevant to
128
+ * the scope). This is the recommended way to scope a key for a given method.
129
+ */
130
+ export function buildEIP712ScopeForTypedData(
131
+ typedData: Pick<EIP712TypedData, "domain" | "types" | "primaryType">,
132
+ ): string {
133
+ const typeHash = eip712TypeHash(typedData.primaryType, typedData.types);
134
+ return buildEIP712Scope(
135
+ typedData.domain.chainId,
136
+ typedData.domain.verifyingContract,
137
+ typeHash,
138
+ );
139
+ }
140
+
65
141
  // ---------------------------------------------------------------------------
66
142
  // Structured signing
67
143
  // ---------------------------------------------------------------------------
@@ -93,17 +169,25 @@ export async function signTypedData(
93
169
  claims: IdTokenClaims,
94
170
  identity?: string,
95
171
  ): Promise<ScopedSignResult> {
96
- // Build session-authenticated request (no message hash payload is sent separately)
97
- // The canonical hash must use the full sub-key ID (identity + suffix).
172
+ // The canonical request hash must use the full sub-key ID (identity + suffix).
98
173
  // Extract suffix from keyId: "oauth:iss:sub:suffix" → suffix is last segment
99
174
  // The identity param is "iss:sub", so we need to add the suffix.
100
175
  const keyParts = keyId.split(":");
101
176
  const keySuffix = keyParts.length > 1 ? keyParts[keyParts.length - 1] : undefined;
102
177
 
103
- const signReq = await signKeygenRequest(
178
+ // Compute the EIP-712 hash locally and bind it into the session request
179
+ // signature. The nodes recompute hashTypedData from the payload and verify
180
+ // the request signature against it — without this binding, a malicious
181
+ // initiator could substitute any payload matching the key's scope.
182
+ const payloadHash = hexToBytes(
183
+ hashTypedData(typedData as Parameters<typeof hashTypedData>[0]).slice(2),
184
+ );
185
+
186
+ const signReq = await signSignRequest(
104
187
  sessionKeypair,
105
188
  claims,
106
189
  groupId,
190
+ payloadHash,
107
191
  keySuffix,
108
192
  identity,
109
193
  );