@buildersgarden/siwa 0.0.13 → 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 CHANGED
@@ -7,4 +7,5 @@ export * from './registry.js';
7
7
  export * from './addresses.js';
8
8
  export * from './receipt.js';
9
9
  export * from './erc8128.js';
10
+ export * from './nonce-store.js';
10
11
  export * from './tba.js';
package/dist/index.js CHANGED
@@ -7,4 +7,5 @@ export * from './registry.js';
7
7
  export * from './addresses.js';
8
8
  export * from './receipt.js';
9
9
  export * from './erc8128.js';
10
+ export * from './nonce-store.js';
10
11
  export * from './tba.js';
@@ -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
@@ -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.13",
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"