@meddleware/nft-gate-gateway 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.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Backend selection — the Workers analog of the Rust gateway's `main.rs` store bootstrap
3
+ * (`REDIS_URL ? redis : in_memory`). Chooses the Durable Object or KV backend from config +
4
+ * available bindings, failing fast if the chosen backend's binding is missing.
5
+ */
6
+
7
+ import type { Config, Env } from '../config.js'
8
+ import type { NonceBackend } from './types.js'
9
+ import type { NonceRateState } from './durable_object.js'
10
+ import { DurableObjectBackend } from './durable_object.js'
11
+ import { KvBackend } from './kv.js'
12
+
13
+ /**
14
+ * Instantiate the appropriate {@link NonceBackend} from config and available bindings.
15
+ * Mirrors the Rust gateway's startup-time store selection (`REDIS_URL ? redis : in_memory`).
16
+ *
17
+ * @param cfg - The resolved gateway config, potentially modified by the quota-degrade flag.
18
+ * @param env - Worker environment bindings.
19
+ * @returns A configured `NonceBackend` instance.
20
+ * @throws If the required binding for the chosen backend is absent.
21
+ */
22
+ export function makeBackend(cfg: Config, env: Env): NonceBackend {
23
+ if (cfg.nonceBackend === 'kv') {
24
+ if (!env.NONCE_KV) throw new Error('NONCE_BACKEND=kv but the NONCE_KV binding is missing')
25
+ return new KvBackend(env.NONCE_KV)
26
+ }
27
+ if (!env.NONCE_STATE) {
28
+ throw new Error('NONCE_BACKEND=durable-object but the NONCE_STATE binding is missing')
29
+ }
30
+ return new DurableObjectBackend(
31
+ env.NONCE_STATE as DurableObjectNamespace<NonceRateState>,
32
+ cfg.nonceShard,
33
+ cfg.nonceMaxEntries,
34
+ )
35
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The pluggable state backend — the Workers analog of the Rust gateway's `NonceStore` enum
3
+ * (in-memory | Redis). It holds the single-use nonce store AND the per-address rate-limit
4
+ * windows, because both need the same per-key atomicity a stateless isolate cannot give.
5
+ *
6
+ * Two implementations:
7
+ * - `DurableObjectBackend` (default): region-sharded, SQLite-backed Durable Objects. Strongly
8
+ * consistent, atomic single-use consume (the Redis-`GETDEL` analog). Free-tier eligible.
9
+ * - `KvBackend`: Workers KV + best-effort rate limit. Eventually consistent (documented weaker
10
+ * cross-region replay window in the `SINGLE_USE=false` ownership mode).
11
+ */
12
+
13
+ export interface NonceBackend {
14
+ /**
15
+ * Issue a fresh, time-bound nonce. `region` selects the DO shard (ignored by KV, which is
16
+ * global). Returns the opaque `<region>.<hex>` nonce and its unix-ms expiry.
17
+ */
18
+ issue(region: string, ttlSecs: number): Promise<{ nonce: string; expiresAt: number }>
19
+ /** Consume a nonce exactly once; true iff it was valid, unexpired, and unused. */
20
+ takeIfValid(nonce: string): Promise<boolean>
21
+ /** Fixed 60s window per verified address. `maxPerMin === 0` disables limiting. */
22
+ rateCheck(address: string, maxPerMin: number, region: string): Promise<boolean>
23
+ }
24
+
25
+ /**
26
+ * Parse the shard tag embedded in a `<region>.<hex>` nonce so a consume call always routes
27
+ * back to the shard that issued it, even under anycast region drift.
28
+ *
29
+ * @param nonce - A nonce in `<region>.<hex>` format.
30
+ * @returns The region prefix (e.g. `"eu"`), or `"g"` if no dot is present.
31
+ */
32
+ export function shardOfNonce(nonce: string): string {
33
+ const dot = nonce.indexOf('.')
34
+ return dot > 0 ? nonce.slice(0, dot) : 'g'
35
+ }
36
+
37
+ /**
38
+ * Generate a cryptographically random 48-character hex string (24 bytes of entropy). Matches
39
+ * the Rust gateway's `random_nonce()` entropy so conformance vectors apply to both.
40
+ *
41
+ * @returns A 48-character lowercase hex string.
42
+ */
43
+ export function randomHex24(): string {
44
+ const buf = new Uint8Array(24)
45
+ crypto.getRandomValues(buf)
46
+ let out = ''
47
+ for (let i = 0; i < buf.length; i++) out += buf[i].toString(16).padStart(2, '0')
48
+ return out
49
+ }
package/src/verify.ts ADDED
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Access verification — the security core. Direct Sui personal-message signature checking
3
+ * plus the allow/deny decision. A 1:1 port of the Rust gateway's `verify.rs`; the on-chain
4
+ * lookups are abstracted behind {@link ChainQuery} so the decision is unit-testable offline.
5
+ */
6
+
7
+ import { ed25519 } from '@noble/curves/ed25519.js'
8
+ import { secp256k1 } from '@noble/curves/secp256k1.js'
9
+ import { p256 } from '@noble/curves/nist.js'
10
+ import type { Config } from './config.js'
11
+ import type { NonceBackend } from './state/types.js'
12
+ import { blake2b256, base64ToBytes, bytesToHex, concatBytes, uleb128 } from './crypto.js'
13
+ import { personalMessageForNonce, decodeAccessProof } from './wire.js'
14
+
15
+ // Sui signature-scheme flag bytes (first byte of a serialized signature).
16
+ const FLAG_ED25519 = 0x00
17
+ const FLAG_SECP256K1 = 0x01
18
+ const FLAG_SECP256R1 = 0x02
19
+ const FLAG_MULTISIG = 0x03
20
+ const FLAG_ZKLOGIN = 0x05
21
+
22
+ /** blake2b256( intent(PersonalMessage,V0,Sui) || bcs(Vec<u8> message) ) — what a Sui wallet signs. */
23
+ export function signingDigest(message: Uint8Array): Uint8Array {
24
+ const intent = Uint8Array.from([3, 0, 0]) // IntentScope::PersonalMessage, V0, AppId::Sui
25
+ return blake2b256(concatBytes(intent, uleb128(message.length), message))
26
+ }
27
+
28
+ /** Sui address for a scheme: `0x` + hex(blake2b256(flag || pubkey)). */
29
+ export function deriveAddress(flag: number, pk: Uint8Array): string {
30
+ return '0x' + bytesToHex(blake2b256(concatBytes(Uint8Array.from([flag]), pk)))
31
+ }
32
+
33
+ export function normalizeAddress(a: string): string {
34
+ const s = a.trim().replace(/^0x/i, '').toLowerCase()
35
+ return '0x' + s.padStart(64, '0')
36
+ }
37
+
38
+ /**
39
+ * Verify a Sui personal-message signature recovers `address` over `message`, dispatching on
40
+ * the scheme flag byte. Serialized layout: `flag(1) || signature || pubkey` (base64).
41
+ *
42
+ * Supported: ed25519 (0x00), secp256k1 (0x01), secp256r1 (0x02). multisig (0x03) and zkLogin
43
+ * (0x05) are recognised but not yet verified (they need the official Sui verifier) and fail
44
+ * **closed** — identical posture to the Rust gateway (audit F1).
45
+ */
46
+ export function verifyPersonalMessageSignature(
47
+ address: string,
48
+ message: Uint8Array,
49
+ signatureB64: string,
50
+ ): boolean {
51
+ let raw: Uint8Array
52
+ try {
53
+ raw = base64ToBytes(signatureB64)
54
+ } catch {
55
+ return false
56
+ }
57
+ if (raw.length === 0) return false
58
+ const flag = raw[0]
59
+ const body = raw.subarray(1)
60
+ switch (flag) {
61
+ case FLAG_ED25519:
62
+ return verifyEd25519(address, message, body)
63
+ case FLAG_SECP256K1:
64
+ return verifyEcdsa(secp256k1, FLAG_SECP256K1, address, message, body)
65
+ case FLAG_SECP256R1:
66
+ return verifyEcdsa(p256, FLAG_SECP256R1, address, message, body)
67
+ case FLAG_MULTISIG:
68
+ case FLAG_ZKLOGIN:
69
+ // Recognised but not yet supported by this verifier (fails closed); see gateway audit F1.
70
+ return false
71
+ default:
72
+ return false
73
+ }
74
+ }
75
+
76
+ /** ed25519 (flag 0x00): body = `sig(64) || pubkey(32)`. */
77
+ function verifyEd25519(address: string, message: Uint8Array, body: Uint8Array): boolean {
78
+ if (body.length !== 96) return false
79
+ const sig = body.subarray(0, 64)
80
+ const pk = body.subarray(64, 96)
81
+ const digest = signingDigest(message)
82
+ let ok = false
83
+ try {
84
+ ok = ed25519.verify(sig, digest, pk)
85
+ } catch {
86
+ return false
87
+ }
88
+ if (!ok) return false
89
+ return normalizeAddress(address) === deriveAddress(FLAG_ED25519, pk)
90
+ }
91
+
92
+ /**
93
+ * secp256k1 (0x01) / secp256r1 (0x02): body = `sig(64, compact r||s) || pubkey(33, SEC1-
94
+ * compressed)`. Sui signs `SHA256(blake2b256(intent||msg))` with ECDSA; noble's `verify` applies
95
+ * the SHA256 step internally, so we pass the blake2b digest and let the curve do the rest.
96
+ * `lowS: true` matches Sui's low-S normalisation.
97
+ */
98
+ function verifyEcdsa(
99
+ curve: typeof secp256k1 | typeof p256,
100
+ flag: number,
101
+ address: string,
102
+ message: Uint8Array,
103
+ body: Uint8Array,
104
+ ): boolean {
105
+ if (body.length !== 97) return false
106
+ const sig = body.subarray(0, 64)
107
+ const pk = body.subarray(64, 97)
108
+ const msgHash = signingDigest(message)
109
+ let ok = false
110
+ try {
111
+ ok = curve.verify(sig, msgHash, pk, { lowS: true })
112
+ } catch {
113
+ return false
114
+ }
115
+ if (!ok) return false
116
+ return normalizeAddress(address) === deriveAddress(flag, pk)
117
+ }
118
+
119
+ /** On-chain lookups needed to authorise a request. Implemented by {@link ../chain.SuiRpc}. */
120
+ export interface ChainQuery {
121
+ ownsNft(address: string, nftType: string, gateId?: string): Promise<boolean>
122
+ consumeEventMatches(
123
+ nonce: string,
124
+ address: string,
125
+ nftType: string,
126
+ gateId?: string,
127
+ consumeDigest?: string,
128
+ ): Promise<boolean>
129
+ }
130
+
131
+ export type Denied =
132
+ | 'BadProof'
133
+ | 'BadSignature'
134
+ | 'NonceInvalid'
135
+ | 'NotOwner'
136
+ | 'ConsumeMissing'
137
+ | 'ChainError'
138
+
139
+ export function deniedReason(d: Denied): string {
140
+ switch (d) {
141
+ case 'BadProof':
142
+ return 'malformed access proof'
143
+ case 'BadSignature':
144
+ return 'signature does not recover address'
145
+ case 'NonceInvalid':
146
+ return 'challenge nonce invalid, expired, or already used'
147
+ case 'NotOwner':
148
+ return 'address does not hold the required access NFT'
149
+ case 'ConsumeMissing':
150
+ return 'no matching single-use consume for this challenge'
151
+ case 'ChainError':
152
+ return 'on-chain verification failed'
153
+ }
154
+ }
155
+
156
+ export type VerifyResult = { ok: true; address: string } | { ok: false; denied: Denied }
157
+
158
+ /**
159
+ * Authorise a request from its base64 proof token. The nonce is consumed (single-use at the
160
+ * gateway) as part of a successful signature check, so a replay presents an already-used
161
+ * nonce and is rejected. Mirror of Rust `verify_access_request`.
162
+ */
163
+ export async function verifyAccessRequest(
164
+ cfg: Config,
165
+ store: NonceBackend,
166
+ token: string,
167
+ chain: ChainQuery,
168
+ ): Promise<VerifyResult> {
169
+ let proof
170
+ try {
171
+ proof = decodeAccessProof(token)
172
+ } catch {
173
+ return { ok: false, denied: 'BadProof' }
174
+ }
175
+
176
+ const message = personalMessageForNonce(proof.nonce)
177
+ if (!verifyPersonalMessageSignature(proof.address, message, proof.signature)) {
178
+ return { ok: false, denied: 'BadSignature' }
179
+ }
180
+
181
+ // Consume the nonce exactly once (fresh, unexpired, unused) — before the chain call.
182
+ if (!(await store.takeIfValid(proof.nonce))) {
183
+ return { ok: false, denied: 'NonceInvalid' }
184
+ }
185
+
186
+ if (cfg.singleUse) {
187
+ if (proof.consumeDigest === undefined) {
188
+ return { ok: false, denied: 'ConsumeMissing' }
189
+ }
190
+ let ok: boolean
191
+ try {
192
+ ok = await chain.consumeEventMatches(
193
+ proof.nonce,
194
+ proof.address,
195
+ cfg.nftType,
196
+ cfg.gateId,
197
+ proof.consumeDigest,
198
+ )
199
+ } catch {
200
+ return { ok: false, denied: 'ChainError' }
201
+ }
202
+ if (!ok) return { ok: false, denied: 'ConsumeMissing' }
203
+ } else {
204
+ let ok: boolean
205
+ try {
206
+ ok = await chain.ownsNft(proof.address, cfg.nftType, cfg.gateId)
207
+ } catch {
208
+ return { ok: false, denied: 'ChainError' }
209
+ }
210
+ if (!ok) return { ok: false, denied: 'NotOwner' }
211
+ }
212
+
213
+ return { ok: true, address: proof.address }
214
+ }
215
+
216
+ export { FLAG_ED25519, FLAG_SECP256K1, FLAG_SECP256R1, FLAG_MULTISIG, FLAG_ZKLOGIN }
package/src/wire.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The shared wire-format helpers, REUSED from `@meddleware/nft-gate-client` rather than
3
+ * re-implemented — there must be exactly one source for `personalMessageForNonce` and the
4
+ * proof token shape across the toolkit (the "one contract that spans all three" rule in
5
+ * nft-gate/CLAUDE.md).
6
+ *
7
+ * Imported by relative path (the in-repo convention) directly from the client's `proof.ts` /
8
+ * `types.ts`, which import only `./types.js` — so this pulls in NO `@mysten/sui` and keeps the
9
+ * Worker bundle lean.
10
+ *
11
+ * Self-contained-mirror alternative (documented for future review): if the Worker must ship
12
+ * fully decoupled from the client package, replace these two lines with a local copy of
13
+ * `personalMessageForNonce` + `decodeAccessProof` guarded by `conformance/vectors.json`.
14
+ */
15
+
16
+ export {
17
+ personalMessageForNonce,
18
+ decodeAccessProof,
19
+ } from '@meddleware/nft-gate-client'
20
+ export type { AccessProof } from '@meddleware/nft-gate-client'
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "types": ["@cloudflare/workers-types"],
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": false,
10
+ "esModuleInterop": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": false,
15
+ "noEmit": true,
16
+ "resolveJsonModule": true
17
+ },
18
+ "include": ["src/**/*.ts", "test/**/*.ts", "../conformance/**/*.json"]
19
+ }
package/wrangler.toml ADDED
@@ -0,0 +1,83 @@
1
+ # nft-gate-gateway — Cloudflare Workers deployment.
2
+ #
3
+ # Wire-identical to the Rust gateway (../gateway). Bind it to the SAME public hostname the
4
+ # Rust gateway serves (see [routes] below) to switch implementations with nothing else changing.
5
+ #
6
+ # OPERATOR SETUP — run these once before deploying, then never touch them again:
7
+ #
8
+ # UPSTREAM_URL (required — your CF-Access-locked relay/upstream origin):
9
+ # wrangler secret put UPSTREAM_URL
10
+ # Value: the full origin URL (e.g. https://your-origin.example.com)
11
+ # This is kept as a secret so it does not appear in this public file and is
12
+ # not overwritten by `wrangler deploy`.
13
+ #
14
+ # NFT_TYPE (required — set after publishing your access_gate contract on-chain):
15
+ # wrangler secret put NFT_TYPE
16
+ # Value: "<PACKAGE_ID>::access_gate::SoulboundAccessNFT"
17
+ # Update this whenever you redeploy the on-chain contract.
18
+ #
19
+ # UPSTREAM_AUTH_HEADERS (required for CF-Access-locked origin):
20
+ # wrangler secret put UPSTREAM_AUTH_HEADERS
21
+ # Value: "CF-Access-Client-Id: <id>, CF-Access-Client-Secret: <secret>"
22
+ # Obtain from: Cloudflare Zero Trust → Access → Service Tokens → create token
23
+ # The Access policy on your origin hostname must allow this token.
24
+ #
25
+ # SUI_RPC_AUTH_HEADER (optional — only if using an authenticated/private RPC):
26
+ # wrangler secret put SUI_RPC_AUTH_HEADER
27
+ # Value: "Authorization: Bearer <token>"
28
+ #
29
+ # CF_ANALYTICS_TOKEN (optional — only if QUOTA_GUARD_ENABLED=true):
30
+ # wrangler secret put CF_ANALYTICS_TOKEN
31
+ # CF_ACCOUNT_ID (optional — only if QUOTA_GUARD_ENABLED=true):
32
+ # wrangler secret put CF_ACCOUNT_ID
33
+
34
+ name = "nft-gate-gateway"
35
+ main = "src/index.ts"
36
+ compatibility_date = "2025-04-01"
37
+ compatibility_flags = ["nodejs_compat"]
38
+
39
+ # ── config (parity with the Rust gateway env; see README.md for the mapping) ─────────────────
40
+ [vars]
41
+ # UPSTREAM_URL and NFT_TYPE are set via `wrangler secret put` (see above) so that
42
+ # deployment-specific values never appear in this public file and are never overwritten
43
+ # by `wrangler deploy`. Do not add them here.
44
+
45
+ # Public fullnode — safe to keep as a plain var (no sensitive information).
46
+ SUI_RPC_URL = "https://fullnode.testnet.sui.io:443"
47
+
48
+ GATE_ID = "" # operator: set to the access_gate registry object ID (single-registry mode)
49
+ SINGLE_USE = "false"
50
+ PUBLIC_PATHS = "/v1/tip-config"
51
+ RATE_LIMIT_PER_MIN = "30"
52
+ MAX_BODY_BYTES = "262144"
53
+ CHALLENGE_TTL_SECS = "300"
54
+ OWNERSHIP_CACHE_TTL_MS = "0"
55
+ # Workers-specific:
56
+ NONCE_BACKEND = "durable-object" # "durable-object" (default) | "kv"
57
+ NONCE_SHARD = "region" # "region" (default) | "global"
58
+ NONCE_MAX_ENTRIES = "1000000"
59
+ QUOTA_GUARD_ENABLED = "false"
60
+
61
+ # ── nonce store + rate limiter: region-sharded, SQLite-backed Durable Objects (free-tier) ────
62
+ [[durable_objects.bindings]]
63
+ name = "NONCE_STATE"
64
+ class_name = "NonceRateState"
65
+
66
+ [[migrations]]
67
+ tag = "v1"
68
+ new_sqlite_classes = ["NonceRateState"]
69
+
70
+ # ── KV backend (only needed when NONCE_BACKEND=kv) ────────────────────────────────────────────
71
+ # [[kv_namespaces]]
72
+ # binding = "NONCE_KV"
73
+ # id = "<KV_NAMESPACE_ID>" # create with: wrangler kv namespace create NONCE_KV
74
+
75
+ # ── route: Worker serves as the public NFT-gated relay endpoint ───────────────────────────────
76
+ # operator: uncomment and fill in after creating the Worker route in the CF dashboard
77
+ # [[routes]]
78
+ # pattern = "nft-gate.example.com/*"
79
+ # zone_name = "example.com"
80
+
81
+ # ── optional quota guard (needs QUOTA_GUARD_ENABLED=true + the secrets above) ────────────────
82
+ # [triggers]
83
+ # crons = ["*/15 * * * *"]