fix smirk login

This commit is contained in:
jwinterm
2026-02-07 11:40:07 -05:00
parent a56063867e
commit f8ae69304a
2 changed files with 233 additions and 2 deletions
+229
View File
@@ -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": "<socket.io id>" }
```
**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": "<socket.io id>",
"challenge": "<the challenge string>",
"publicKey": "<Ed25519 public key, hex>",
"signature": "<Ed25519 signature, hex>"
}
```
**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=<socket.io id>`
**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<PublicKeys>`
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<SignResult>`
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<Addresses>`
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<boolean>`
Check if the current origin is connected (approved).
### `smirk.disconnect()` -> `Promise<void>`
Revoke site access.
### `smirk.getPublicKeys()` -> `Promise<PublicKeys | null>`
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.
+4 -2
View File
@@ -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', {