@buildersgarden/siwa 0.0.1

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/dist/siwa.js ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * siwa.ts
3
+ *
4
+ * SIWA (Sign In With Agent) utility functions.
5
+ * Provides message building, signing (agent-side), and verification (server-side).
6
+ *
7
+ * Dependencies:
8
+ * npm install ethers
9
+ */
10
+ import { ethers } from 'ethers';
11
+ import * as crypto from 'crypto';
12
+ import { getAgent, getReputation } from './registry.js';
13
+ // ─── Message Construction ────────────────────────────────────────────
14
+ /**
15
+ * Build a SIWA plaintext message string from structured fields.
16
+ */
17
+ export function buildSIWAMessage(fields) {
18
+ const lines = [];
19
+ lines.push(`${fields.domain} wants you to sign in with your Agent account:`);
20
+ lines.push(fields.address);
21
+ lines.push('');
22
+ if (fields.statement) {
23
+ lines.push(fields.statement);
24
+ }
25
+ lines.push('');
26
+ lines.push(`URI: ${fields.uri}`);
27
+ lines.push(`Version: ${fields.version || '1'}`);
28
+ lines.push(`Agent ID: ${fields.agentId}`);
29
+ lines.push(`Agent Registry: ${fields.agentRegistry}`);
30
+ lines.push(`Chain ID: ${fields.chainId}`);
31
+ lines.push(`Nonce: ${fields.nonce}`);
32
+ lines.push(`Issued At: ${fields.issuedAt}`);
33
+ if (fields.expirationTime)
34
+ lines.push(`Expiration Time: ${fields.expirationTime}`);
35
+ if (fields.notBefore)
36
+ lines.push(`Not Before: ${fields.notBefore}`);
37
+ if (fields.requestId)
38
+ lines.push(`Request ID: ${fields.requestId}`);
39
+ return lines.join('\n');
40
+ }
41
+ /**
42
+ * Parse a SIWA message string back into structured fields.
43
+ */
44
+ export function parseSIWAMessage(message) {
45
+ const lines = message.split('\n');
46
+ const domainMatch = lines[0]?.match(/^(.+) wants you to sign in with your Agent account:$/);
47
+ if (!domainMatch)
48
+ throw new Error('Invalid SIWA message: missing domain line');
49
+ const domain = domainMatch[1];
50
+ const address = lines[1];
51
+ if (!address || !address.startsWith('0x') || address.length !== 42) {
52
+ throw new Error('Invalid SIWA message: missing or malformed address');
53
+ }
54
+ // Find fields after the blank lines
55
+ const fieldMap = {};
56
+ let statement;
57
+ let inStatement = false;
58
+ const stmtLines = [];
59
+ for (let i = 2; i < lines.length; i++) {
60
+ const line = lines[i];
61
+ if (i === 2 && line === '') {
62
+ inStatement = true;
63
+ continue;
64
+ }
65
+ if (inStatement) {
66
+ if (line === '' || line.startsWith('URI: ')) {
67
+ inStatement = false;
68
+ statement = stmtLines.join('\n').trim() || undefined;
69
+ if (line.startsWith('URI: ')) {
70
+ const [key, ...rest] = line.split(': ');
71
+ fieldMap[key] = rest.join(': ');
72
+ }
73
+ continue;
74
+ }
75
+ stmtLines.push(line);
76
+ continue;
77
+ }
78
+ if (line.includes(': ')) {
79
+ const [key, ...rest] = line.split(': ');
80
+ fieldMap[key] = rest.join(': ');
81
+ }
82
+ }
83
+ return {
84
+ domain,
85
+ address,
86
+ statement,
87
+ uri: fieldMap['URI'] || '',
88
+ version: fieldMap['Version'] || '1',
89
+ agentId: parseInt(fieldMap['Agent ID'] || '0'),
90
+ agentRegistry: fieldMap['Agent Registry'] || '',
91
+ chainId: parseInt(fieldMap['Chain ID'] || '0'),
92
+ nonce: fieldMap['Nonce'] || '',
93
+ issuedAt: fieldMap['Issued At'] || '',
94
+ expirationTime: fieldMap['Expiration Time'],
95
+ notBefore: fieldMap['Not Before'],
96
+ requestId: fieldMap['Request ID'],
97
+ };
98
+ }
99
+ // ─── Nonce Generation ────────────────────────────────────────────────
100
+ /**
101
+ * Generate a cryptographically secure nonce (≥ 8 alphanumeric characters).
102
+ */
103
+ export function generateNonce(length = 16) {
104
+ return crypto.randomBytes(length).toString('base64url').slice(0, length);
105
+ }
106
+ // ─── Agent-Side Signing ──────────────────────────────────────────────
107
+ /**
108
+ * Sign a SIWA message using the secure keystore.
109
+ *
110
+ * The private key is loaded from the keystore, used to sign, and discarded.
111
+ * It is NEVER returned or exposed to the caller.
112
+ *
113
+ * @param fields — SIWA message fields (domain, agentId, etc.)
114
+ * @param keystoreConfig — Optional keystore configuration override
115
+ * @returns { message, signature } — only the plaintext message and EIP-191 signature
116
+ */
117
+ export async function signSIWAMessage(fields, keystoreConfig) {
118
+ // Import keystore dynamically to avoid circular deps
119
+ const { signMessage, getAddress } = await import('./keystore');
120
+ // Verify the keystore address matches the claimed address
121
+ const keystoreAddress = await getAddress(keystoreConfig);
122
+ if (!keystoreAddress) {
123
+ throw new Error('No wallet found in keystore. Run createWallet() first.');
124
+ }
125
+ if (keystoreAddress.toLowerCase() !== fields.address.toLowerCase()) {
126
+ throw new Error(`Address mismatch: keystore has ${keystoreAddress}, message claims ${fields.address}`);
127
+ }
128
+ const message = buildSIWAMessage(fields);
129
+ // Sign via keystore — private key is loaded, used, and discarded internally
130
+ const result = await signMessage(message, keystoreConfig);
131
+ return { message, signature: result.signature };
132
+ }
133
+ /**
134
+ * Sign a SIWA message using a raw private key.
135
+ * ⚠️ DEPRECATED: Use signSIWAMessage() with keystore instead.
136
+ * Kept only for server-side testing or environments without keystore.
137
+ */
138
+ export async function signSIWAMessageUnsafe(privateKey, fields) {
139
+ const wallet = new ethers.Wallet(privateKey);
140
+ if (wallet.address.toLowerCase() !== fields.address.toLowerCase()) {
141
+ throw new Error(`Address mismatch: wallet is ${wallet.address}, message says ${fields.address}`);
142
+ }
143
+ const message = buildSIWAMessage(fields);
144
+ const signature = await wallet.signMessage(message);
145
+ return { message, signature };
146
+ }
147
+ // ─── Server-Side Verification ────────────────────────────────────────
148
+ /**
149
+ * Verify a SIWA message + signature.
150
+ *
151
+ * Checks:
152
+ * 1. Message format validity
153
+ * 2. Signature → address recovery
154
+ * 3. Address matches message
155
+ * 4. Domain matches expected domain
156
+ * 5. Nonce matches (caller must validate against their nonce store)
157
+ * 6. Time window (expirationTime / notBefore)
158
+ * 7. Onchain: ownerOf(agentId) === recovered address
159
+ *
160
+ * @param message Full SIWA message string
161
+ * @param signature EIP-191 signature hex string
162
+ * @param expectedDomain The server's domain (for domain binding)
163
+ * @param nonceValid Callback that returns true if the nonce is valid and unconsumed
164
+ * @param provider ethers Provider for onchain verification
165
+ * @param criteria Optional criteria to validate agent profile/reputation after ownership check
166
+ */
167
+ export async function verifySIWA(message, signature, expectedDomain, nonceValid, provider, criteria) {
168
+ try {
169
+ // 1. Parse
170
+ const fields = parseSIWAMessage(message);
171
+ // 2. Recover signer
172
+ const recovered = ethers.verifyMessage(message, signature);
173
+ // 3. Address match
174
+ if (recovered.toLowerCase() !== fields.address.toLowerCase()) {
175
+ return { valid: false, address: recovered, agentId: fields.agentId, agentRegistry: fields.agentRegistry, chainId: fields.chainId, error: 'Recovered address does not match message address' };
176
+ }
177
+ // 4. Domain binding
178
+ if (fields.domain !== expectedDomain) {
179
+ return { valid: false, address: recovered, agentId: fields.agentId, agentRegistry: fields.agentRegistry, chainId: fields.chainId, error: `Domain mismatch: expected ${expectedDomain}, got ${fields.domain}` };
180
+ }
181
+ // 5. Nonce
182
+ const nonceOk = await nonceValid(fields.nonce);
183
+ if (!nonceOk) {
184
+ return { valid: false, address: recovered, agentId: fields.agentId, agentRegistry: fields.agentRegistry, chainId: fields.chainId, error: 'Invalid or consumed nonce' };
185
+ }
186
+ // 6. Time window
187
+ const now = new Date();
188
+ if (fields.expirationTime && now > new Date(fields.expirationTime)) {
189
+ return { valid: false, address: recovered, agentId: fields.agentId, agentRegistry: fields.agentRegistry, chainId: fields.chainId, error: 'Message expired' };
190
+ }
191
+ if (fields.notBefore && now < new Date(fields.notBefore)) {
192
+ return { valid: false, address: recovered, agentId: fields.agentId, agentRegistry: fields.agentRegistry, chainId: fields.chainId, error: 'Message not yet valid (notBefore)' };
193
+ }
194
+ // 7. Onchain ownership — extract registry address from agentRegistry string
195
+ const registryParts = fields.agentRegistry.split(':');
196
+ if (registryParts.length !== 3 || registryParts[0] !== 'eip155') {
197
+ return { valid: false, address: recovered, agentId: fields.agentId, agentRegistry: fields.agentRegistry, chainId: fields.chainId, error: 'Invalid agentRegistry format' };
198
+ }
199
+ const registryAddress = registryParts[2];
200
+ const registry = new ethers.Contract(registryAddress, ['function ownerOf(uint256) view returns (address)'], provider);
201
+ const owner = await registry.ownerOf(fields.agentId);
202
+ if (owner.toLowerCase() !== recovered.toLowerCase()) {
203
+ // 7b. ERC-1271 fallback for smart contract wallets / EIP-7702 delegated accounts.
204
+ // If ecrecover doesn't match the NFT owner, the owner may be a contract
205
+ // that validates signatures via isValidSignature (ERC-1271).
206
+ const messageHash = ethers.hashMessage(message);
207
+ try {
208
+ const ownerContract = new ethers.Contract(owner, ['function isValidSignature(bytes32, bytes) view returns (bytes4)'], provider);
209
+ const magicValue = await ownerContract.isValidSignature(messageHash, signature);
210
+ // ERC-1271 magic value: 0x1626ba7e
211
+ if (magicValue !== '0x1626ba7e') {
212
+ return { valid: false, address: recovered, agentId: fields.agentId, agentRegistry: fields.agentRegistry, chainId: fields.chainId, error: 'Signer is not the owner of this agent NFT (ERC-1271 check also failed)' };
213
+ }
214
+ // ERC-1271 validated — the owner contract accepted the signature
215
+ }
216
+ catch {
217
+ // Owner is not a contract or doesn't implement ERC-1271
218
+ return { valid: false, address: recovered, agentId: fields.agentId, agentRegistry: fields.agentRegistry, chainId: fields.chainId, error: 'Signer is not the owner of this agent NFT' };
219
+ }
220
+ }
221
+ // 8. Criteria checks (optional)
222
+ const baseResult = {
223
+ valid: true,
224
+ address: recovered,
225
+ agentId: fields.agentId,
226
+ agentRegistry: fields.agentRegistry,
227
+ chainId: fields.chainId,
228
+ };
229
+ if (!criteria)
230
+ return baseResult;
231
+ const agent = await getAgent(fields.agentId, {
232
+ registryAddress: registryAddress,
233
+ provider,
234
+ fetchMetadata: true,
235
+ });
236
+ baseResult.agent = agent;
237
+ if (criteria.mustBeActive) {
238
+ if (!agent.metadata?.active) {
239
+ return { ...baseResult, valid: false, error: 'Agent is not active' };
240
+ }
241
+ }
242
+ if (criteria.requiredServices && criteria.requiredServices.length > 0) {
243
+ const serviceNames = (agent.metadata?.services ?? []).map(s => s.name);
244
+ for (const required of criteria.requiredServices) {
245
+ if (!serviceNames.includes(required)) {
246
+ return { ...baseResult, valid: false, error: `Agent missing required service: ${required}` };
247
+ }
248
+ }
249
+ }
250
+ if (criteria.requiredTrust && criteria.requiredTrust.length > 0) {
251
+ const supported = agent.metadata?.supportedTrust ?? [];
252
+ for (const required of criteria.requiredTrust) {
253
+ if (!supported.includes(required)) {
254
+ return { ...baseResult, valid: false, error: `Agent missing required trust model: ${required}` };
255
+ }
256
+ }
257
+ }
258
+ if (criteria.minScore !== undefined || criteria.minFeedbackCount !== undefined) {
259
+ if (!criteria.reputationRegistryAddress) {
260
+ return { ...baseResult, valid: false, error: 'reputationRegistryAddress is required for reputation criteria' };
261
+ }
262
+ const rep = await getReputation(fields.agentId, {
263
+ reputationRegistryAddress: criteria.reputationRegistryAddress,
264
+ provider,
265
+ });
266
+ if (criteria.minFeedbackCount !== undefined && rep.count < criteria.minFeedbackCount) {
267
+ return { ...baseResult, valid: false, error: `Agent feedback count ${rep.count} below minimum ${criteria.minFeedbackCount}` };
268
+ }
269
+ if (criteria.minScore !== undefined && rep.score < criteria.minScore) {
270
+ return { ...baseResult, valid: false, error: `Agent reputation score ${rep.score} below minimum ${criteria.minScore}` };
271
+ }
272
+ }
273
+ if (criteria.custom) {
274
+ const passed = await criteria.custom(agent);
275
+ if (!passed) {
276
+ return { ...baseResult, valid: false, error: 'Agent failed custom criteria check' };
277
+ }
278
+ }
279
+ return baseResult;
280
+ }
281
+ catch (err) {
282
+ return { valid: false, address: '', agentId: 0, agentRegistry: '', chainId: 0, error: err.message || 'Verification failed' };
283
+ }
284
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@buildersgarden/siwa",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
7
+ "./keystore": { "types": "./dist/keystore.d.ts", "default": "./dist/keystore.js" },
8
+ "./siwa": { "types": "./dist/siwa.d.ts", "default": "./dist/siwa.js" },
9
+ "./memory": { "types": "./dist/memory.d.ts", "default": "./dist/memory.js" },
10
+ "./proxy-auth": { "types": "./dist/proxy-auth.d.ts", "default": "./dist/proxy-auth.js" },
11
+ "./registry": { "types": "./dist/registry.d.ts", "default": "./dist/registry.js" }
12
+ },
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "files": ["dist"],
16
+ "publishConfig": { "access": "public" },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "clean": "rm -rf dist"
20
+ },
21
+ "dependencies": {
22
+ "ethers": "^6.14.3"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^5.5.0"
26
+ }
27
+ }