@oleary-labs/signet-sdk 0.1.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/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
  );