diff --git a/cspell.json b/cspell.json index edb3392..3d8167a 100644 --- a/cspell.json +++ b/cspell.json @@ -66,6 +66,7 @@ "johndoe", "keccak", "keypair", + "nullifiers", "kotlinx", "Lazo", "listas", @@ -149,6 +150,7 @@ "sybil", "SYNCMODE", "tamperable", + "TIMESTAMPTZ", "testnet", "testnets", "thirdweb", @@ -156,6 +158,7 @@ "tunnelmole", "tute", "uniffi", + "unlinkable", "unpackedProof", "urlencode", "userop", diff --git a/docs.json b/docs.json index 3b05f4a..1ced9a5 100644 --- a/docs.json +++ b/docs.json @@ -49,6 +49,7 @@ "group": "IDKit", "pages": [ "world-id/idkit/integrate", + "world-id/idkit/signatures", "world-id/idkit/build-with-llms", "world-id/idkit/onchain-verification", "world-id/idkit/design-guidelines", diff --git a/world-id/idkit/go.mdx b/world-id/idkit/go.mdx index 46f2c7f..aa242b7 100644 --- a/world-id/idkit/go.mdx +++ b/world-id/idkit/go.mdx @@ -16,12 +16,18 @@ go get github.com/worldcoin/idkit/go/idkit@latest ## Generate RP signature -Use `idkit.SignRequestWithTTL` instead to configure the signature's expiration time (default is 5 minutes). +### One-shot signing + +Use `SignRequest` with functional options for the simplest integration: ```go import "github.com/worldcoin/idkit/go/idkit" -sig, err := idkit.SignRequest(os.Getenv("RP_SIGNING_KEY")) +// For uniqueness proofs: include the action +sig, err := idkit.SignRequest( + os.Getenv("RP_SIGNING_KEY"), + idkit.WithAction("my-action"), +) if err != nil { // handle error } @@ -35,11 +41,52 @@ rpContext := map[string]any{ } ``` +### Reusable signer + +For high-throughput backends, create a `Signer` once and reuse it. This parses the key upfront and avoids repeated allocations. + +```go +signer, err := idkit.NewSigner(os.Getenv("RP_SIGNING_KEY")) +if err != nil { + log.Fatal(err) +} + +// Use in your request handler +sig, err := signer.SignRequest( + idkit.WithAction("my-action"), + idkit.WithTTL(600), // optional, default 300s +) +``` + ## API -- `SignRequest(signingKeyHex)` -- `SignRequestWithTTL(signingKeyHex, ttl)` +### Functions + +| Function | Description | +|----------|-------------| +| `SignRequest(signingKeyHex, opts...)` | One-shot signing with options | +| `SignRequestWithTTL(signingKeyHex, ttl)` | Convenience wrapper with custom TTL | +| `NewSigner(signingKeyHex)` | Creates a reusable `Signer` from a hex key | + +### Options + +| Option | Description | +|--------|-------------| +| `WithAction(action)` | Hashes and appends the action to the signed payload (required for uniqueness proofs) | +| `WithTTL(ttl)` | Overrides the default 300-second TTL | + +### `RpSignature` + +```go +type RpSignature struct { + Sig string `json:"sig"` // 0x-prefixed, 65-byte hex + Nonce string `json:"nonce"` // 0x-prefixed, 32-byte field element + CreatedAt uint64 `json:"created_at"` // Unix seconds + ExpiresAt uint64 `json:"expires_at"` // Unix seconds +} +``` ## Related pages +- [RP Signatures](/world-id/idkit/signatures) — algorithm details, pseudocode, and test vectors - [Integrate IDKit](/world-id/idkit/integrate) diff --git a/world-id/idkit/integrate.mdx b/world-id/idkit/integrate.mdx index 1d7a2c8..4743cda 100644 --- a/world-id/idkit/integrate.mdx +++ b/world-id/idkit/integrate.mdx @@ -47,13 +47,15 @@ Signatures verify that proof requests genuinely come from your app, preventing a ```typescript title="JavaScript" import { NextResponse } from "next/server"; -import { signRequest } from "@worldcoin/idkit/signing"; +import { signRequest } from "@worldcoin/idkit-core/signing"; export async function POST(request: Request): Promise { const { action } = await request.json(); - const signingKey = process.env.RP_SIGNING_KEY!; - const { sig, nonce, createdAt, expiresAt } = signRequest(action, signingKey); + const { sig, nonce, createdAt, expiresAt } = signRequest({ + signingKeyHex: process.env.RP_SIGNING_KEY!, + action, + }); return NextResponse.json({ sig, @@ -81,7 +83,15 @@ func handleRPSignature(w http.ResponseWriter, r *http.Request) { return } - sig, err := idkit.SignRequest(os.Getenv("RP_SIGNING_KEY")) + var body struct { + Action string `json:"action"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + + sig, err := idkit.SignRequest( + os.Getenv("RP_SIGNING_KEY"), + idkit.WithAction(body.Action), + ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -105,7 +115,7 @@ func handleRPSignature(w http.ResponseWriter, r *http.Request) { # Step 4: Generate the connect URL and collect proof -You can test during development using the [simulator](https://simulator.worldcoin.org/) and setting `environment` to `"staging"`. +You can test during development using the [simulator](https://simulator.worldcoin.org/) and setting `environment` to `"staging"`. ```typescript title="JavaScript" @@ -122,7 +132,7 @@ const request = await IDKit.request({ app_id: "app_xxxxx", // Action: Context that scopes what the user is proving uniqueness for // e.g., "verify-account-2026" or "claim-airdrop-2026". - action: "my-action", + action: "my-action", rp_context: { rp_id: "rp_xxxxx", // Your app's `rp_id` from the Developer Portal nonce: rpSig.nonce, @@ -132,7 +142,8 @@ const request = await IDKit.request({ }, allow_legacy_proofs: true, environment: "production", // Only set this to staging for testing with the simulator - // Signal (optional): Bind specific context into the requested proof. + return_to: "myapp://verify-done", // Optional: mobile deep-link callback URL + // Signal (optional): Bind specific context into the requested proof. // Examples: user ID, wallet address. Your backend should enforce the same value. }).preset(orbLegacy({ signal: "local-election-1" })); @@ -300,11 +311,33 @@ export async function POST(request: Request): Promise { }, ); - const payload = await response.json(); - return NextResponse.json(payload, { status: response.status }); + if (!response.ok) { + return NextResponse.json({ error: "Verification failed" }, { status: 400 }); + } + + // Proof is valid — now store the nullifier (see Step 6) + return NextResponse.json({ success: true }); } ``` +# Step 6: Store the nullifier + +Every World ID proof contains a nullifier — a value derived from the user's World ID, your app, and the action. The same person verifying the same action always produces the same nullifier, but different apps or actions produce different ones — making nullifiers unlinkable across apps. + +The Developer Portal confirms the proof is **cryptographically valid**, but your backend must check that the nullifier hasn't been used before. Without this, the same person could verify multiple times for the same action. + +Nullifiers are returned as 0x-prefixed hex strings representing 256-bit integers. We recommend converting and storing them as numbers to avoid parsing and casing issues that can lead to security vulnerabilities. For example, PostgreSQL doesn't natively support 256-bit integers, instead you can convert to the nullifier to a decimal and store it as `NUMERIC(78, 0)`. + + +```sql title="PostgreSQL schema" +CREATE TABLE nullifiers ( + nullifier NUMERIC(78, 0) NOT NULL, + action TEXT NOT NULL, + verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (nullifier, action) +); +``` + ## Architecture overview @@ -328,9 +361,11 @@ sequenceDiagram Client->>Backend: 6. Forward proof payload Backend->>Portal: POST /v4/verify/{rp_id} Portal-->>Backend: Verification result + Backend->>Backend: 7. Check & store nullifier Backend-->>Client: Success / failure ``` ## Next pages +- [RP Signatures](/world-id/idkit/signatures) — algorithm details, pseudocode, and test vectors - [POST /v4/verify reference](/api-reference/developer-portal/verify) diff --git a/world-id/idkit/javascript.mdx b/world-id/idkit/javascript.mdx index 2cd9fdd..8d82e55 100644 --- a/world-id/idkit/javascript.mdx +++ b/world-id/idkit/javascript.mdx @@ -46,11 +46,10 @@ const builder = IDKit.request({ expires_at: 1735689900, signature: "0x...", }, - action_description: "Verify user", - bridge_url: undefined, allow_legacy_proofs: true, - override_connect_base_url: undefined, environment: "production", // Only set this to staging for testing with the simulator + return_to: "myapp://verify-done", // Optional: mobile deep-link callback URL + bridge_url: undefined, // Optional: custom bridge URL }); ``` @@ -111,19 +110,21 @@ Use subpath exports on your backend: import { signRequest } from "@worldcoin/idkit-core/signing"; import { hashSignal } from "@worldcoin/idkit-core/hashing"; -const { sig, nonce, createdAt, expiresAt } = signRequest( - "my-action", - process.env.RP_SIGNING_KEY!, -); +const { sig, nonce, createdAt, expiresAt } = signRequest({ + signingKeyHex: process.env.RP_SIGNING_KEY!, + action: "my-action", + ttl: 300, // optional, default 300s +}); const signalHash = hashSignal("user-123"); ``` -`signRequest` should only run in trusted server environments. +`signRequest` should only run in trusted server environments. See [RP Signatures](/world-id/idkit/signatures) for the full algorithm and test vectors. ## Related pages - [Getting started](/world-id/idkit/integrate) +- [RP Signatures](/world-id/idkit/signatures) - [React](/world-id/idkit/react) - [Error Codes](/world-id/idkit/error-codes) - [POST /v4/verify reference](/api-reference/developer-portal/verify) diff --git a/world-id/idkit/kotlin.mdx b/world-id/idkit/kotlin.mdx index 5785d50..66b0891 100644 --- a/world-id/idkit/kotlin.mdx +++ b/world-id/idkit/kotlin.mdx @@ -54,10 +54,8 @@ val config = IDKitRequestConfig( appId = "app_xxxxx", action = "my-action", rpContext = rpContext, - actionDescription = "Verify user", - bridgeUrl = null, allowLegacyProofs = true, - overrideConnectBaseUrl = null, + returnTo = "myapp://verify-done", // Optional: mobile deep-link callback URL environment = Environment.PRODUCTION, ) diff --git a/world-id/idkit/react.mdx b/world-id/idkit/react.mdx index b7306a5..872f3d8 100644 --- a/world-id/idkit/react.mdx +++ b/world-id/idkit/react.mdx @@ -132,3 +132,5 @@ For RP signature generation in React/Next.js apps, use the pure JS subpath: ```ts import { signRequest } from "@worldcoin/idkit/signing"; ``` + +See [RP Signatures](/world-id/idkit/signatures) for the full algorithm and test vectors. diff --git a/world-id/idkit/signatures.mdx b/world-id/idkit/signatures.mdx new file mode 100644 index 0000000..ed0a11c --- /dev/null +++ b/world-id/idkit/signatures.mdx @@ -0,0 +1,194 @@ +--- +title: "RP Signatures" +description: "Spec for generating RP signatures, with pseudocode, SDK examples, and test vectors." +"og:image": "https://raw.githubusercontent.com/worldcoin/developer-docs/main/images/docs/docs-meta.png" +"twitter:image": "https://raw.githubusercontent.com/worldcoin/developer-docs/main/images/docs/docs-meta.png" +--- + +Relying Party (RP) signatures prove that a proof request genuinely comes from your app, preventing impersonation attacks. +Your backend signs every request with the `signing_key` from the [Developer Portal](https://developer.world.org), +and World App verifies the signature before generating a proof. RP signatures are enforced for [World ID 4.0 requests](/world-id/4-0-migration). + + + Never expose your signing key to client-side code. If the key leaks, rotate it immediately in the Developer Portal. + + +## Algorithm + + +```text title="Implement it yourself" +// IMPORTANT: Use Keccak-256, NOT SHA3-256. They have different padding. +// Most Ethereum libraries (ethers, viem, web3) use Keccak-256. + +function hash_to_field(input_bytes) -> bytes32: + h = keccak256(input_bytes) // 32 bytes + n = big_endian_uint256(h) >> 8 // shift right 8 bits + return uint256_to_32bytes_be(n) // always starts with 0x00 + +function compute_rp_signature_message(nonce_bytes32, created_at_u64, expires_at_u64, action?) -> bytes: + size = 81 if action else 49 + msg = new bytes(size) + msg[0] = 0x01 // version byte + msg[1..32] = nonce_bytes32 // 32-byte field element + msg[33..40] = u64_to_be(created_at) // big-endian uint64 + msg[41..48] = u64_to_be(expires_at) // big-endian uint64 + + if action is not null: + msg[49..80] = hash_to_field(utf8_encode(action)) + + return msg + +function sign_request(signing_key_hex, action?, ttl_seconds = 300): + // Accept signing keys with or without 0x prefix + key = parse_hex_32_bytes(signing_key_hex) + + // 1. Generate nonce + random = crypto_random_bytes(32) + nonce_bytes = hash_to_field(random) + + // 2. Timestamps + created_at = unix_time_seconds() + expires_at = created_at + ttl_seconds + + // 3. Build message + msg = compute_rp_signature_message(nonce_bytes, created_at, expires_at, action) + + // 4. EIP-191 prefix and hash + // The prefix uses the DECIMAL byte length of the message (e.g. "49" or "81") + prefix = "\x19Ethereum Signed Message:\n" + decimal_string(length(msg)) + digest = keccak256(prefix + msg) + + // 5. Sign with recoverable ECDSA (secp256k1) + (r, s, recovery_id) = ecdsa_secp256k1_sign(digest, key) + + // 6. Encode: r(32) || s(32) || v(1), where v = recovery_id + 27 + sig65 = r + s + byte(recovery_id + 27) + + return { + sig: "0x" + hex(sig65), + nonce: "0x" + hex(nonce_bytes), + created_at: created_at, + expires_at: expires_at, + } +``` + +```typescript title="JavaScript / TypeScript" +// Also available from @worldcoin/idkit/signing and @worldcoin/idkit-core/signing +import { signRequest } from "@worldcoin/idkit-server"; + +const sig = signRequest({ + signingKeyHex: process.env.RP_SIGNING_KEY!, + action: "my-action", + ttl: 300, // optional, default 300s +}); + +// sig = { sig, nonce, createdAt, expiresAt } +``` + +```go title="Go" +import "github.com/worldcoin/idkit/go/idkit" + +// One-shot signing with options +sig, err := idkit.SignRequest( + os.Getenv("RP_SIGNING_KEY"), + idkit.WithAction("my-action"), + idkit.WithTTL(300), // optional, default 300s +) + +// Or create a reusable signer for high-throughput backends +signer, err := idkit.NewSigner(os.Getenv("RP_SIGNING_KEY")) +sig, err = signer.SignRequest(idkit.WithAction("my-action")) +``` + + +## Test vectors + +Use these to verify your implementation. All vectors use deterministic inputs. + +### `hash_to_field` + + +```text title="empty string" +input: "" (empty) +output: 0x00c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4 +``` + +```text title='"test_signal"' +input: "test_signal" +output: 0x00c1636e0a961a3045054c4d61374422c31a95846b8442f0927ad2ff1d6112ed +``` + +```text title="raw bytes" +input: [0x01, 0x02, 0x03] +output: 0x00f1885eda54b7a053318cd41e2093220dab15d65381b1157a3633a83bfd5c92 +``` + +```text title='"hello"' +input: "hello" (0x68656c6c6f) +output: 0x001c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36dea +``` + + +### `compute_rp_signature_message` + + +```text title="without action (49 bytes)" +compute_rp_signature_message( + nonce = 0x008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd, + created_at = 1700000000, + expires_at = 1700000300, +) + +output: +01008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd000000006553f100000000006553f22c +``` + +```text title='with action "test-action" (81 bytes)' +compute_rp_signature_message( + nonce = 0x008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd, + created_at = 1700000000, + expires_at = 1700000300, + action = "test-action", +) + +output: +01008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd000000006553f100000000006553f22c00aa0ce59768ae5b1c52f07a9387f14f09f277422c0d2f8a268c7bad0c60a46a +``` + + +### `sign_request` + + +```text title="without action (session proof)" +sign_request( + signing_key = 0xabababababababababababababababababababababababababababababababab, + random = [0x00, 0x01, ..., 0x1f], // deterministic for testing + created_at = 1700000000, // fixed clock for testing + ttl = 300, +) + +nonce: 0x008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd +msg length: 49 bytes +sig: 0x14f693175773aed912852a601e9c0fd30f2afe2738d31388316232ce6f64ae9e4edbfb19d81c4229ba9c9fca78ede4b28956b7ba4415f08d957cbc1b3bdaa4021b +``` + +```text title='with action "test-action" (uniqueness proof)' +sign_request( + signing_key = 0xabababababababababababababababababababababababababababababababab, + action = "test-action", + random = [0x00, 0x01, ..., 0x1f], // deterministic for testing + created_at = 1700000000, // fixed clock for testing + ttl = 300, +) + +nonce: 0x008ae1aa597fa146ebd3aa2ceddf360668dea5e526567e92b0321816a4e895bd +msg length: 81 bytes +sig: 0x05594adb6c1495768a38d523d7d6ee6356b2c31231919198794ed022ade7d08f73753f83bd167067d99c9b969d28e9222315837c66af25867b041273a6d5056f1b +``` + + +## Related pages + +- [Integration guide](/world-id/idkit/integrate) +- [JavaScript SDK reference](/world-id/idkit/javascript) +- [Go SDK reference](/world-id/idkit/go) diff --git a/world-id/idkit/swift.mdx b/world-id/idkit/swift.mdx index 5ae1eb5..b74f87b 100644 --- a/world-id/idkit/swift.mdx +++ b/world-id/idkit/swift.mdx @@ -37,10 +37,8 @@ let config = IDKitRequestConfig( appId: "app_xxxxx", action: "my-action", rpContext: rpContext, - actionDescription: "Verify user", - bridgeUrl: nil, allowLegacyProofs: true, - overrideConnectBaseUrl: nil, + returnTo: "myapp://verify-done", // Optional: mobile deep-link callback URL environment: .production )