@buildersgarden/siwa 0.0.12 → 0.0.14
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/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/nonce-store.d.ts +101 -0
- package/dist/nonce-store.js +135 -0
- package/dist/siwa.d.ts +4 -0
- package/dist/siwa.js +9 -1
- package/package.json +5 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nonce-store.ts
|
|
3
|
+
*
|
|
4
|
+
* Pluggable nonce store for SIWA sign-in replay protection.
|
|
5
|
+
*
|
|
6
|
+
* The default HMAC-based stateless nonces are convenient but can be replayed
|
|
7
|
+
* within their TTL window. A SIWANonceStore tracks issued nonces server-side
|
|
8
|
+
* so each nonce can only be consumed once.
|
|
9
|
+
*
|
|
10
|
+
* Built-in factories:
|
|
11
|
+
* - createMemorySIWANonceStore() — single-process (Map + TTL)
|
|
12
|
+
* - createRedisSIWANonceStore(redis) — ioredis / node-redis
|
|
13
|
+
* - createKVSIWANonceStore(kv) — Cloudflare Workers KV
|
|
14
|
+
*
|
|
15
|
+
* For databases (SQL, Prisma, Drizzle), implement the SIWANonceStore interface
|
|
16
|
+
* directly — it's just two methods.
|
|
17
|
+
*
|
|
18
|
+
* No new runtime dependencies — each factory accepts a minimal interface so
|
|
19
|
+
* users bring their own client.
|
|
20
|
+
*/
|
|
21
|
+
export interface SIWANonceStore {
|
|
22
|
+
/** Store an issued nonce. Returns true on success, false if already exists. */
|
|
23
|
+
issue(nonce: string, ttlMs: number): Promise<boolean>;
|
|
24
|
+
/** Atomically check-and-delete a nonce. Returns true if it existed (valid), false otherwise. */
|
|
25
|
+
consume(nonce: string): Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* In-memory nonce store with TTL-based expiry.
|
|
29
|
+
*
|
|
30
|
+
* Suitable for single-process servers. For multi-instance deployments,
|
|
31
|
+
* implement SIWANonceStore with a shared store (Redis, database, etc.).
|
|
32
|
+
*/
|
|
33
|
+
export declare function createMemorySIWANonceStore(): SIWANonceStore;
|
|
34
|
+
/**
|
|
35
|
+
* Minimal subset of the ioredis / node-redis API used by the nonce store.
|
|
36
|
+
* Both ioredis and node-redis v4 (with legacyMode or the `.set()` overload)
|
|
37
|
+
* satisfy this interface out of the box.
|
|
38
|
+
*/
|
|
39
|
+
export interface RedisLikeClient {
|
|
40
|
+
set(...args: unknown[]): Promise<unknown>;
|
|
41
|
+
del(...args: unknown[]): Promise<number>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Redis-backed nonce store.
|
|
45
|
+
*
|
|
46
|
+
* Uses `SET key 1 PX ttl NX` for atomic issue (fails if key exists) and
|
|
47
|
+
* `DEL key` for atomic consume (returns 1 only on first delete).
|
|
48
|
+
*
|
|
49
|
+
* @param redis An ioredis instance or any client matching `RedisLikeClient`.
|
|
50
|
+
* @param prefix Optional key prefix (default `"siwa:nonce:"`).
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* // ioredis
|
|
55
|
+
* import Redis from "ioredis";
|
|
56
|
+
* const redis = new Redis();
|
|
57
|
+
* const nonceStore = createRedisSIWANonceStore(redis);
|
|
58
|
+
*
|
|
59
|
+
* // node-redis v4 — wrap with a tiny adapter:
|
|
60
|
+
* import { createClient } from "redis";
|
|
61
|
+
* const client = createClient(); await client.connect();
|
|
62
|
+
* const nonceStore = createRedisSIWANonceStore({
|
|
63
|
+
* set: (...a: unknown[]) => client.set(a[0] as string, a[1] as string,
|
|
64
|
+
* { PX: a[3] as number, NX: true }).then(r => r ?? null),
|
|
65
|
+
* del: (k: unknown) => client.del(k as string),
|
|
66
|
+
* });
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export declare function createRedisSIWANonceStore(redis: RedisLikeClient, prefix?: string): SIWANonceStore;
|
|
70
|
+
/**
|
|
71
|
+
* Minimal subset of the Cloudflare KV namespace binding API.
|
|
72
|
+
*/
|
|
73
|
+
export interface KVNamespaceLike {
|
|
74
|
+
put(key: string, value: string, options?: {
|
|
75
|
+
expirationTtl?: number;
|
|
76
|
+
}): Promise<void>;
|
|
77
|
+
get(key: string): Promise<string | null>;
|
|
78
|
+
delete(key: string): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Cloudflare Workers KV-backed nonce store.
|
|
82
|
+
*
|
|
83
|
+
* `issue` uses `put` with an `expirationTtl` so nonces auto-expire.
|
|
84
|
+
* `consume` does a `get` + `delete` pair — not fully atomic, but acceptable
|
|
85
|
+
* for random 128-bit nonces where collisions are astronomically unlikely.
|
|
86
|
+
*
|
|
87
|
+
* @param kv A KV namespace binding (e.g. `env.SIWA_NONCES`).
|
|
88
|
+
* @param prefix Optional key prefix (default `"siwa:nonce:"`).
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* // In a Cloudflare Worker
|
|
93
|
+
* export default {
|
|
94
|
+
* async fetch(request, env) {
|
|
95
|
+
* const nonceStore = createKVSIWANonceStore(env.SIWA_NONCES);
|
|
96
|
+
* // use with createSIWANonce / verifySIWA
|
|
97
|
+
* },
|
|
98
|
+
* };
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export declare function createKVSIWANonceStore(kv: KVNamespaceLike, prefix?: string): SIWANonceStore;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nonce-store.ts
|
|
3
|
+
*
|
|
4
|
+
* Pluggable nonce store for SIWA sign-in replay protection.
|
|
5
|
+
*
|
|
6
|
+
* The default HMAC-based stateless nonces are convenient but can be replayed
|
|
7
|
+
* within their TTL window. A SIWANonceStore tracks issued nonces server-side
|
|
8
|
+
* so each nonce can only be consumed once.
|
|
9
|
+
*
|
|
10
|
+
* Built-in factories:
|
|
11
|
+
* - createMemorySIWANonceStore() — single-process (Map + TTL)
|
|
12
|
+
* - createRedisSIWANonceStore(redis) — ioredis / node-redis
|
|
13
|
+
* - createKVSIWANonceStore(kv) — Cloudflare Workers KV
|
|
14
|
+
*
|
|
15
|
+
* For databases (SQL, Prisma, Drizzle), implement the SIWANonceStore interface
|
|
16
|
+
* directly — it's just two methods.
|
|
17
|
+
*
|
|
18
|
+
* No new runtime dependencies — each factory accepts a minimal interface so
|
|
19
|
+
* users bring their own client.
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* In-memory nonce store with TTL-based expiry.
|
|
23
|
+
*
|
|
24
|
+
* Suitable for single-process servers. For multi-instance deployments,
|
|
25
|
+
* implement SIWANonceStore with a shared store (Redis, database, etc.).
|
|
26
|
+
*/
|
|
27
|
+
export function createMemorySIWANonceStore() {
|
|
28
|
+
const nonces = new Map(); // nonce → expiry timestamp (ms)
|
|
29
|
+
function cleanup() {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
for (const [k, expiry] of nonces) {
|
|
32
|
+
if (expiry < now)
|
|
33
|
+
nonces.delete(k);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
async issue(nonce, ttlMs) {
|
|
38
|
+
cleanup();
|
|
39
|
+
if (nonces.has(nonce))
|
|
40
|
+
return false;
|
|
41
|
+
nonces.set(nonce, Date.now() + ttlMs);
|
|
42
|
+
return true;
|
|
43
|
+
},
|
|
44
|
+
async consume(nonce) {
|
|
45
|
+
cleanup();
|
|
46
|
+
if (!nonces.has(nonce))
|
|
47
|
+
return false;
|
|
48
|
+
const expiry = nonces.get(nonce);
|
|
49
|
+
nonces.delete(nonce);
|
|
50
|
+
return expiry >= Date.now();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Redis-backed nonce store.
|
|
56
|
+
*
|
|
57
|
+
* Uses `SET key 1 PX ttl NX` for atomic issue (fails if key exists) and
|
|
58
|
+
* `DEL key` for atomic consume (returns 1 only on first delete).
|
|
59
|
+
*
|
|
60
|
+
* @param redis An ioredis instance or any client matching `RedisLikeClient`.
|
|
61
|
+
* @param prefix Optional key prefix (default `"siwa:nonce:"`).
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* // ioredis
|
|
66
|
+
* import Redis from "ioredis";
|
|
67
|
+
* const redis = new Redis();
|
|
68
|
+
* const nonceStore = createRedisSIWANonceStore(redis);
|
|
69
|
+
*
|
|
70
|
+
* // node-redis v4 — wrap with a tiny adapter:
|
|
71
|
+
* import { createClient } from "redis";
|
|
72
|
+
* const client = createClient(); await client.connect();
|
|
73
|
+
* const nonceStore = createRedisSIWANonceStore({
|
|
74
|
+
* set: (...a: unknown[]) => client.set(a[0] as string, a[1] as string,
|
|
75
|
+
* { PX: a[3] as number, NX: true }).then(r => r ?? null),
|
|
76
|
+
* del: (k: unknown) => client.del(k as string),
|
|
77
|
+
* });
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export function createRedisSIWANonceStore(redis, prefix = 'siwa:nonce:') {
|
|
81
|
+
return {
|
|
82
|
+
async issue(nonce, ttlMs) {
|
|
83
|
+
// SET key "1" PX ttlMs NX → "OK" if the key was set, null otherwise
|
|
84
|
+
const result = await redis.set(prefix + nonce, '1', 'PX', ttlMs, 'NX');
|
|
85
|
+
return result === 'OK';
|
|
86
|
+
},
|
|
87
|
+
async consume(nonce) {
|
|
88
|
+
// DEL returns the number of keys removed (1 = existed, 0 = didn't)
|
|
89
|
+
const deleted = await redis.del(prefix + nonce);
|
|
90
|
+
return deleted === 1;
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Cloudflare Workers KV-backed nonce store.
|
|
96
|
+
*
|
|
97
|
+
* `issue` uses `put` with an `expirationTtl` so nonces auto-expire.
|
|
98
|
+
* `consume` does a `get` + `delete` pair — not fully atomic, but acceptable
|
|
99
|
+
* for random 128-bit nonces where collisions are astronomically unlikely.
|
|
100
|
+
*
|
|
101
|
+
* @param kv A KV namespace binding (e.g. `env.SIWA_NONCES`).
|
|
102
|
+
* @param prefix Optional key prefix (default `"siwa:nonce:"`).
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* // In a Cloudflare Worker
|
|
107
|
+
* export default {
|
|
108
|
+
* async fetch(request, env) {
|
|
109
|
+
* const nonceStore = createKVSIWANonceStore(env.SIWA_NONCES);
|
|
110
|
+
* // use with createSIWANonce / verifySIWA
|
|
111
|
+
* },
|
|
112
|
+
* };
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
export function createKVSIWANonceStore(kv, prefix = 'siwa:nonce:') {
|
|
116
|
+
return {
|
|
117
|
+
async issue(nonce, ttlMs) {
|
|
118
|
+
const key = prefix + nonce;
|
|
119
|
+
// Check-before-write: KV put is unconditional, so read first
|
|
120
|
+
const existing = await kv.get(key);
|
|
121
|
+
if (existing !== null)
|
|
122
|
+
return false;
|
|
123
|
+
await kv.put(key, '1', { expirationTtl: Math.ceil(ttlMs / 1000) });
|
|
124
|
+
return true;
|
|
125
|
+
},
|
|
126
|
+
async consume(nonce) {
|
|
127
|
+
const key = prefix + nonce;
|
|
128
|
+
const value = await kv.get(key);
|
|
129
|
+
if (value === null)
|
|
130
|
+
return false;
|
|
131
|
+
await kv.delete(key);
|
|
132
|
+
return true;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
package/dist/siwa.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { type PublicClient } from 'viem';
|
|
11
11
|
import { AgentProfile, ServiceType, TrustModel } from './registry.js';
|
|
12
12
|
import type { Signer, SignerType } from './signer.js';
|
|
13
|
+
import type { SIWANonceStore } from './nonce-store.js';
|
|
13
14
|
export declare enum SIWAErrorCode {
|
|
14
15
|
INVALID_SIGNATURE = "INVALID_SIGNATURE",
|
|
15
16
|
DOMAIN_MISMATCH = "DOMAIN_MISMATCH",
|
|
@@ -117,6 +118,7 @@ export interface SIWANonceParams {
|
|
|
117
118
|
export interface SIWANonceOptions {
|
|
118
119
|
expirationTTL?: number;
|
|
119
120
|
secret?: string;
|
|
121
|
+
nonceStore?: SIWANonceStore;
|
|
120
122
|
}
|
|
121
123
|
export type SIWANonceResult = {
|
|
122
124
|
status: 'nonce_issued';
|
|
@@ -203,6 +205,8 @@ export declare function signSIWAMessage(fields: SIWASignFields, signer: Signer):
|
|
|
203
205
|
export type NonceValidator = ((nonce: string) => boolean | Promise<boolean>) | {
|
|
204
206
|
nonceToken: string;
|
|
205
207
|
secret: string;
|
|
208
|
+
} | {
|
|
209
|
+
nonceStore: SIWANonceStore;
|
|
206
210
|
};
|
|
207
211
|
/**
|
|
208
212
|
* Verify a SIWA message + signature.
|
package/dist/siwa.js
CHANGED
|
@@ -43,7 +43,7 @@ export function buildSIWAResponse(result) {
|
|
|
43
43
|
const skillRef = {
|
|
44
44
|
name: '@buildersgarden/siwa',
|
|
45
45
|
install: 'npm install @buildersgarden/siwa',
|
|
46
|
-
url: 'https://siwa.
|
|
46
|
+
url: 'https://siwa.id/skill.md',
|
|
47
47
|
};
|
|
48
48
|
if (result.valid) {
|
|
49
49
|
return { status: 'authenticated', ...base };
|
|
@@ -272,6 +272,10 @@ export async function createSIWANonce(params, client, options) {
|
|
|
272
272
|
const now = new Date();
|
|
273
273
|
const expiresAt = new Date(now.getTime() + ttl);
|
|
274
274
|
const nonce = generateNonce();
|
|
275
|
+
// Track nonce in the store (for replay protection)
|
|
276
|
+
if (options?.nonceStore) {
|
|
277
|
+
await options.nonceStore.issue(nonce, ttl);
|
|
278
|
+
}
|
|
275
279
|
const result = {
|
|
276
280
|
status: 'nonce_issued',
|
|
277
281
|
nonce,
|
|
@@ -385,6 +389,10 @@ export async function verifySIWA(message, signature, expectedDomain, nonceValid,
|
|
|
385
389
|
if (typeof nonceValid === 'function') {
|
|
386
390
|
nonceOk = await nonceValid(fields.nonce);
|
|
387
391
|
}
|
|
392
|
+
else if ('nonceStore' in nonceValid) {
|
|
393
|
+
// Store-based validation: atomic consume (check + delete)
|
|
394
|
+
nonceOk = await nonceValid.nonceStore.consume(fields.nonce);
|
|
395
|
+
}
|
|
388
396
|
else {
|
|
389
397
|
// Stateless validation via HMAC token
|
|
390
398
|
const payload = verifyNonceToken(nonceValid.nonceToken, nonceValid.secret);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buildersgarden/siwa",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"types": "./dist/erc8128.d.ts",
|
|
44
44
|
"default": "./dist/erc8128.js"
|
|
45
45
|
},
|
|
46
|
+
"./nonce-store": {
|
|
47
|
+
"types": "./dist/nonce-store.d.ts",
|
|
48
|
+
"default": "./dist/nonce-store.js"
|
|
49
|
+
},
|
|
46
50
|
"./next": {
|
|
47
51
|
"types": "./dist/next.d.ts",
|
|
48
52
|
"default": "./dist/next.js"
|