@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/README.md +75 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/keystore.d.ts +126 -0
- package/dist/keystore.js +355 -0
- package/dist/memory.d.ts +37 -0
- package/dist/memory.js +134 -0
- package/dist/proxy-auth.d.ts +27 -0
- package/dist/proxy-auth.js +58 -0
- package/dist/registry.d.ts +74 -0
- package/dist/registry.js +89 -0
- package/dist/siwa.d.ts +99 -0
- package/dist/siwa.js +284 -0
- package/package.json +27 -0
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
|
+
}
|