diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md new file mode 100644 index 0000000..f65ccf8 --- /dev/null +++ b/docs/INTEGRATION.md @@ -0,0 +1,229 @@ +# Smirk Wallet Integration + +How Wownerogue integrates with the [Smirk](https://smirk.cash) browser extension for wallet-based authentication. + +--- + +## Overview + +Smirk injects a `window.smirk` API (similar to MetaMask's `window.ethereum`) that websites can use to: + +1. Request wallet connection (get public keys) +2. Request message signatures (for authentication) +3. Get wallet addresses (for payouts) + +Wownerogue uses the WOW (Wownero) key for authentication via a challenge-response signature flow. + +--- + +## Authentication Flow + +``` +Client Server Extension + | | | + |-- POST /challenge --------->| | + |<-------- { challenge } -----| | + | | | + |-- smirk.connect() ---------------------------------->| + |<-------------- { publicKeys } -----------------------| + | | | + |-- smirk.signMessage(challenge) --------------------->| + |<-------------- { signatures[] } ---------------------| + | | | + |-- POST /verify ------------>| | + | { challenge, publicKey, | | + | signature, socketId } | | + |<------ { linked: true } ----| | + | | | + |-- smirk.getAddresses() ------------------------------>| + |<-------------- { wow: "Wo..." } ---------------------| + | | | + |-- socket: address:update -->| | +``` + +--- + +## API Endpoints + +### POST `/api/auth/smirk/challenge` + +Generate a challenge for signature verification. + +**Request:** +```json +{ "socketId": "" } +``` + +**Response:** +```json +{ "challenge": "<64-char hex string>", "expiresIn": 300 } +``` + +The challenge is a random 32-byte hex string stored in the database with a 5-minute TTL. + +### POST `/api/auth/smirk/verify` + +Verify a wallet signature and link it to the user session. + +**Request:** +```json +{ + "socketId": "", + "challenge": "", + "publicKey": "", + "signature": "" +} +``` + +**Response (success):** +```json +{ "success": true, "linked": true, "message": "Smirk wallet linked successfully" } +``` + +**Errors:** +- `400` - Missing fields, invalid/expired challenge, invalid signature, or wallet already linked to another account +- `404` - No active session for the given socketId + +### GET `/api/auth/smirk/status` + +Check if a session has a linked wallet. + +**Query:** `?socketId=` + +**Response:** +```json +{ "linked": true, "hasPayoutAddress": true } +``` + +--- + +## Signature Verification + +The Smirk extension signs messages using Ed25519 with a **SHA256 prehash**: + +1. Extension receives the challenge string (e.g. `"a1b2c3..."`) +2. Extension computes `msgHash = SHA256(UTF8_encode(challenge))` +3. Extension signs `msgHash` with the Ed25519 private spend key +4. Extension returns the 64-byte signature as hex + +The server must verify against the same prehash: + +```javascript +const crypto = require('crypto'); +const nacl = require('tweetnacl'); + +const challengeHash = crypto.createHash('sha256').update(challenge).digest(); +const signatureBytes = Buffer.from(signature, 'hex'); +const publicKeyBytes = Buffer.from(publicKey, 'hex'); + +const valid = nacl.sign.detached.verify(challengeHash, signatureBytes, publicKeyBytes); +``` + +**Important:** Do NOT verify against `Buffer.from(challenge, 'hex')` (the raw hex-decoded bytes). The extension always SHA256-hashes the message string before signing. + +--- + +## `window.smirk` API Reference + +The extension injects a frozen `window.smirk` object on all pages. + +### Detection + +```javascript +if (typeof window.smirk !== 'undefined') { + // Extension is installed +} +``` + +### `smirk.connect()` -> `Promise` + +Requests user approval to share public keys. Opens an approval popup on first use; subsequent calls from an approved origin return keys immediately. + +```javascript +const keys = await window.smirk.connect(); +// keys.wow - Ed25519 public spend key (hex) +// keys.btc - secp256k1 compressed public key (hex) +// keys.ltc - secp256k1 compressed public key (hex) +// keys.xmr - Ed25519 public spend key (hex) +// keys.grin - Ed25519 public key (hex) +``` + +### `smirk.signMessage(message)` -> `Promise` + +Signs a message with all wallet keys. Always prompts for user approval. + +```javascript +const result = await window.smirk.signMessage(challenge); +const wowSig = result.signatures.find(s => s.asset === 'wow'); +// wowSig.signature - 64-byte Ed25519 signature (hex) +// wowSig.publicKey - public key that signed (hex) +``` + +Message limit: 10,000 characters. + +### `smirk.getAddresses()` -> `Promise` + +Returns wallet addresses for all assets. Requires prior `connect()`. + +```javascript +const addrs = await window.smirk.getAddresses(); +// addrs.wow - "Wo..." (97-char CryptoNote standard address) +// addrs.btc - "bc1q..." (bech32 P2WPKH) +// addrs.ltc - "ltc1q..." (bech32 P2WPKH) +// addrs.xmr - "4..." (95-char CryptoNote standard address) +// addrs.grin - "grin1..." (bech32 slatepack address) +``` + +### `smirk.isConnected()` -> `Promise` + +Check if the current origin is connected (approved). + +### `smirk.disconnect()` -> `Promise` + +Revoke site access. + +### `smirk.getPublicKeys()` -> `Promise` + +Get public keys without prompting (only works if already connected). Returns `null` if not connected. + +--- + +## Client-Side Implementation + +See `html/js/network/smirkAuth.js` for the full implementation. Key points: + +- The `SmirkAuth` module is initialized after DOM ready and socket connection +- It checks `SocketHandlers._smirkEnabled` (set by server's `game_mode_info` event) to gate the feature +- On successful auth, it calls `smirk.getAddresses()` to auto-set the payout address via `socket.emit('address:update')` +- The button shows install link if extension is not detected, connect button if it is + +--- + +## Database + +The `smirk_challenges` table stores pending challenges: + +| Column | Type | Description | +|--------|------|-------------| +| id | serial | Primary key | +| challenge | text | 64-char hex challenge string | +| socket_id | text | Socket.io session ID | +| used | boolean | Whether challenge has been consumed | +| expires_at | timestamp | Auto-set to NOW() + 5 minutes | +| created_at | timestamp | Row creation time | + +The `users` table has a `smirk_public_key` column that stores the linked Ed25519 public key (hex). + +--- + +## Signature Schemes by Asset + +| Asset | Curve | Hash | Format | +|-------|-------|------|--------| +| BTC | secp256k1 (ECDSA) | Double SHA256 with Bitcoin message prefix | Compact signature (64 bytes) | +| LTC | secp256k1 (ECDSA) | Double SHA256 with Bitcoin message prefix | Compact signature (64 bytes) | +| XMR | Ed25519 | SHA256 prehash | R \|\| s (64 bytes) | +| WOW | Ed25519 | SHA256 prehash | R \|\| s (64 bytes) | +| GRIN | Ed25519 | SHA256 prehash | R \|\| s (64 bytes) | + +Wownerogue only uses the WOW signature for authentication. diff --git a/src/index.js b/src/index.js index 63ee8eb..eff98ec 100644 --- a/src/index.js +++ b/src/index.js @@ -520,12 +520,14 @@ app.post('/api/auth/smirk/verify', asyncHandler(async (req, res) => { let isValidSignature = false; try { const nacl = require('tweetnacl'); - const challengeBytes = Buffer.from(challenge, 'hex'); + // The Smirk extension signs SHA256(utf8(challenge)), not the raw challenge bytes. + // We must hash the challenge the same way before verifying. + const challengeHash = crypto.createHash('sha256').update(challenge).digest(); const signatureBytes = Buffer.from(signature, 'hex'); const publicKeyBytes = Buffer.from(publicKey, 'hex'); // tweetnacl.sign.detached.verify(message, signature, publicKey) - isValidSignature = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes); + isValidSignature = nacl.sign.detached.verify(challengeHash, signatureBytes, publicKeyBytes); } catch (verifyError) { console.error('Signature verification error:', verifyError.message); throw new ValidationError('Signature verification failed', {