From 6ffcf1684bac69cdce0099e64f8a0c1871eb14ec Mon Sep 17 00:00:00 2001 From: Justin Holmes Date: Mon, 12 Jan 2026 01:19:44 +0000 Subject: [PATCH 1/3] Replace API key auth with wallet signature auth - Add auth.js module with signature verification using viem - Verify signer is in AUTHORIZED_WALLETS env var (comma-separated) - Reject timestamps older than 5 minutes (replay protection) - Add 10 tests covering all auth scenarios - Update README with new auth flow Vault now needs: pinning_authorized_wallets (not pinning_service_api_key) --- maybelle/ansible/maybelle.yml | 2 +- maybelle/pinning-service/.gitignore | 2 + maybelle/pinning-service/README.md | 34 +++++-- maybelle/pinning-service/auth.js | 114 +++++++++++++++++++++ maybelle/pinning-service/auth.test.js | 139 ++++++++++++++++++++++++++ maybelle/pinning-service/package.json | 9 +- maybelle/pinning-service/server.js | 25 ++--- 7 files changed, 298 insertions(+), 27 deletions(-) create mode 100644 maybelle/pinning-service/.gitignore create mode 100644 maybelle/pinning-service/auth.js create mode 100644 maybelle/pinning-service/auth.test.js diff --git a/maybelle/ansible/maybelle.yml b/maybelle/ansible/maybelle.yml index 8e46d0e..156f88d 100644 --- a/maybelle/ansible/maybelle.yml +++ b/maybelle/ansible/maybelle.yml @@ -1007,7 +1007,7 @@ - PORT=3001 - PINATA_API_KEY={{ pinata_api_key }} - PINATA_SECRET_KEY={{ pinata_secret_key }} - - PINNING_API_KEY={{ pinning_service_api_key }} + - AUTHORIZED_WALLETS={{ pinning_authorized_wallets }} - IPFS_API_URL=http://ipfs:5001 - STAGING_DIR=/staging volumes: diff --git a/maybelle/pinning-service/.gitignore b/maybelle/pinning-service/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/maybelle/pinning-service/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/maybelle/pinning-service/README.md b/maybelle/pinning-service/README.md index 1826d9c..f8110ab 100644 --- a/maybelle/pinning-service/README.md +++ b/maybelle/pinning-service/README.md @@ -2,15 +2,27 @@ Downloads videos from Instagram/YouTube via yt-dlp and pins them to IPFS (both Pinata cloud and local node). +## Authentication + +Uses wallet-based authentication. Callers must: +1. Sign a message: `Authorize Blue Railroad pinning\nTimestamp: {unix_timestamp_ms}` +2. Include headers: `X-Signature` and `X-Timestamp` +3. Signature must be from an authorized wallet (configured via `AUTHORIZED_WALLETS` env var) +4. Timestamp must be within 5 minutes of server time + +This ensures only wallet owners can authorize pinning operations - no shared secrets required. + ## API Endpoints ### POST /pin-from-url Download video from URL and pin to IPFS. ```bash +# First sign the message with your wallet, then: curl -X POST https://pinning.maybelle.cryptograss.live/pin-from-url \ -H "Content-Type: application/json" \ - -H "X-API-Key: YOUR_API_KEY" \ + -H "X-Signature: 0x..." \ + -H "X-Timestamp: 1704067200000" \ -d '{"url": "https://www.instagram.com/p/ABC123/"}' ``` @@ -31,7 +43,8 @@ Upload and pin a file directly. ```bash curl -X POST https://pinning.maybelle.cryptograss.live/pin-file \ - -H "X-API-Key: YOUR_API_KEY" \ + -H "X-Signature: 0x..." \ + -H "X-Timestamp: 1704067200000" \ -F "file=@video.mp4" ``` @@ -41,7 +54,8 @@ Pin an existing CID to local IPFS node. ```bash curl -X POST https://pinning.maybelle.cryptograss.live/pin-cid \ -H "Content-Type: application/json" \ - -H "X-API-Key: YOUR_API_KEY" \ + -H "X-Signature: 0x..." \ + -H "X-Timestamp: 1704067200000" \ -d '{"cid": "QmXyz..."}' ``` @@ -58,9 +72,9 @@ Add these to `secrets/vault.yml`: pinata_api_key: "your-pinata-api-key" pinata_secret_key: "your-pinata-secret-api-key" -# API key for authenticating requests to the pinning service -# Generate with: openssl rand -hex 32 -pinning_service_api_key: "your-random-api-key" +# Comma-separated list of wallet addresses authorized to use the pinning service +# Typically the Blue Railroad contract owner +pinning_authorized_wallets: "0x4f84b3650dbf651732a41647618e7ff94a633f09" ``` ## Storage @@ -73,3 +87,11 @@ pinning_service_api_key: "your-random-api-key" - 3001: Pinning service API (exposed via Caddy at pinning.maybelle.cryptograss.live) - 5001: IPFS API (localhost only) - 4001: IPFS swarm (public, for peering with other nodes) + +## Testing + +```bash +npm test +``` + +Runs the auth module tests (signature verification, timestamp validation, wallet authorization). diff --git a/maybelle/pinning-service/auth.js b/maybelle/pinning-service/auth.js new file mode 100644 index 0000000..6c0d8c2 --- /dev/null +++ b/maybelle/pinning-service/auth.js @@ -0,0 +1,114 @@ +import { verifyMessage } from 'viem'; + +// Message format: "Authorize Blue Railroad pinning\nTimestamp: {timestamp}" +// Timestamp must be within 5 minutes of server time to prevent replay attacks +const MAX_TIMESTAMP_DRIFT_MS = 5 * 60 * 1000; // 5 minutes + +// Authorized wallets - can be contract owner or allowlisted addresses +// Loaded from environment variable as comma-separated list +function getAuthorizedWallets() { + const walletsEnv = process.env.AUTHORIZED_WALLETS || ''; + return walletsEnv + .split(',') + .map(w => w.trim().toLowerCase()) + .filter(w => w.length > 0); +} + +/** + * Create the message that must be signed for authorization + * @param {number} timestamp - Unix timestamp in milliseconds + * @returns {string} The message to sign + */ +export function createAuthMessage(timestamp) { + return `Authorize Blue Railroad pinning\nTimestamp: ${timestamp}`; +} + +/** + * Verify a signed authorization message + * @param {string} signature - The signature (0x...) + * @param {number} timestamp - The timestamp that was signed + * @param {number} [now] - Current time for testing (defaults to Date.now()) + * @returns {Promise<{valid: boolean, address?: string, error?: string}>} + */ +export async function verifyAuth(signature, timestamp, now = Date.now()) { + // Check timestamp is within acceptable range + const drift = Math.abs(now - timestamp); + if (drift > MAX_TIMESTAMP_DRIFT_MS) { + return { + valid: false, + error: `Timestamp too old or too far in future (drift: ${Math.round(drift / 1000)}s, max: ${MAX_TIMESTAMP_DRIFT_MS / 1000}s)` + }; + } + + // Recover signer address from signature + const message = createAuthMessage(timestamp); + let address; + try { + // verifyMessage returns boolean, we need recoverMessageAddress for the address + const { recoverMessageAddress } = await import('viem'); + address = await recoverMessageAddress({ + message, + signature, + }); + } catch (error) { + return { + valid: false, + error: `Invalid signature: ${error.message}` + }; + } + + // Check if address is authorized + const authorizedWallets = getAuthorizedWallets(); + + // If no wallets configured, reject all (fail secure) + if (authorizedWallets.length === 0) { + return { + valid: false, + address, + error: 'No authorized wallets configured' + }; + } + + const isAuthorized = authorizedWallets.includes(address.toLowerCase()); + if (!isAuthorized) { + return { + valid: false, + address, + error: `Wallet ${address} is not authorized` + }; + } + + return { + valid: true, + address + }; +} + +/** + * Express middleware to require wallet signature auth + */ +export function requireWalletAuth(req, res, next) { + const signature = req.headers['x-signature']; + const timestamp = parseInt(req.headers['x-timestamp'], 10); + + if (!signature || !timestamp) { + return res.status(401).json({ + error: 'Missing authentication headers', + required: ['X-Signature', 'X-Timestamp'] + }); + } + + verifyAuth(signature, timestamp) + .then(result => { + if (!result.valid) { + return res.status(401).json({ error: result.error }); + } + // Attach verified address to request for logging + req.verifiedAddress = result.address; + next(); + }) + .catch(error => { + console.error('Auth error:', error); + res.status(500).json({ error: 'Authentication failed' }); + }); +} diff --git a/maybelle/pinning-service/auth.test.js b/maybelle/pinning-service/auth.test.js new file mode 100644 index 0000000..827eedc --- /dev/null +++ b/maybelle/pinning-service/auth.test.js @@ -0,0 +1,139 @@ +import { createAuthMessage, verifyAuth } from './auth.js'; +import { privateKeyToAccount } from 'viem/accounts'; + +// Test wallet - DO NOT use in production +const TEST_PRIVATE_KEY = '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; +const testAccount = privateKeyToAccount(TEST_PRIVATE_KEY); +const TEST_ADDRESS = testAccount.address; // 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + +describe('createAuthMessage', () => { + it('creates message with correct format', () => { + const timestamp = 1704067200000; // 2024-01-01 00:00:00 UTC + const message = createAuthMessage(timestamp); + expect(message).toBe('Authorize Blue Railroad pinning\nTimestamp: 1704067200000'); + }); +}); + +describe('verifyAuth', () => { + const originalEnv = process.env.AUTHORIZED_WALLETS; + + beforeEach(() => { + // Set test wallet as authorized + process.env.AUTHORIZED_WALLETS = TEST_ADDRESS; + }); + + afterEach(() => { + process.env.AUTHORIZED_WALLETS = originalEnv; + }); + + it('accepts valid signature from authorized wallet', async () => { + const now = Date.now(); + const message = createAuthMessage(now); + const signature = await testAccount.signMessage({ message }); + + const result = await verifyAuth(signature, now, now); + + expect(result.valid).toBe(true); + expect(result.address.toLowerCase()).toBe(TEST_ADDRESS.toLowerCase()); + }); + + it('rejects signature with expired timestamp', async () => { + const now = Date.now(); + const oldTimestamp = now - (6 * 60 * 1000); // 6 minutes ago + const message = createAuthMessage(oldTimestamp); + const signature = await testAccount.signMessage({ message }); + + const result = await verifyAuth(signature, oldTimestamp, now); + + expect(result.valid).toBe(false); + expect(result.error).toContain('Timestamp too old'); + }); + + it('rejects signature with future timestamp', async () => { + const now = Date.now(); + const futureTimestamp = now + (6 * 60 * 1000); // 6 minutes from now + const message = createAuthMessage(futureTimestamp); + const signature = await testAccount.signMessage({ message }); + + const result = await verifyAuth(signature, futureTimestamp, now); + + expect(result.valid).toBe(false); + expect(result.error).toContain('too far in future'); + }); + + it('accepts signature within 5 minute window', async () => { + const now = Date.now(); + const timestamp = now - (4 * 60 * 1000); // 4 minutes ago (within limit) + const message = createAuthMessage(timestamp); + const signature = await testAccount.signMessage({ message }); + + const result = await verifyAuth(signature, timestamp, now); + + expect(result.valid).toBe(true); + }); + + it('rejects signature from unauthorized wallet', async () => { + // Use a different private key + const unauthorizedKey = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d'; + const unauthorizedAccount = privateKeyToAccount(unauthorizedKey); + + const now = Date.now(); + const message = createAuthMessage(now); + const signature = await unauthorizedAccount.signMessage({ message }); + + const result = await verifyAuth(signature, now, now); + + expect(result.valid).toBe(false); + expect(result.error).toContain('not authorized'); + expect(result.address).toBeDefined(); + }); + + it('rejects when no authorized wallets configured', async () => { + process.env.AUTHORIZED_WALLETS = ''; + + const now = Date.now(); + const message = createAuthMessage(now); + const signature = await testAccount.signMessage({ message }); + + const result = await verifyAuth(signature, now, now); + + expect(result.valid).toBe(false); + expect(result.error).toContain('No authorized wallets configured'); + }); + + it('accepts multiple authorized wallets', async () => { + const otherAddress = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8'; + process.env.AUTHORIZED_WALLETS = `${TEST_ADDRESS},${otherAddress}`; + + const now = Date.now(); + const message = createAuthMessage(now); + const signature = await testAccount.signMessage({ message }); + + const result = await verifyAuth(signature, now, now); + + expect(result.valid).toBe(true); + }); + + it('handles malformed signature gracefully', async () => { + const now = Date.now(); + const badSignature = '0xdeadbeef'; + + const result = await verifyAuth(badSignature, now, now); + + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid signature'); + }); + + it('is case-insensitive for wallet addresses', async () => { + // Set authorized wallet in lowercase + process.env.AUTHORIZED_WALLETS = TEST_ADDRESS.toLowerCase(); + + const now = Date.now(); + const message = createAuthMessage(now); + const signature = await testAccount.signMessage({ message }); + + const result = await verifyAuth(signature, now, now); + + expect(result.valid).toBe(true); + }); +}); diff --git a/maybelle/pinning-service/package.json b/maybelle/pinning-service/package.json index 016eb7c..461d9be 100644 --- a/maybelle/pinning-service/package.json +++ b/maybelle/pinning-service/package.json @@ -5,12 +5,17 @@ "main": "server.js", "type": "module", "scripts": { - "start": "node server.js" + "start": "node server.js", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" }, "dependencies": { "express": "^4.18.2", "multer": "^1.4.5-lts.1", "node-fetch": "^3.3.2", - "form-data": "^4.0.0" + "form-data": "^4.0.0", + "viem": "^2.0.0" + }, + "devDependencies": { + "jest": "^29.7.0" } } diff --git a/maybelle/pinning-service/server.js b/maybelle/pinning-service/server.js index 7ba6b73..84edc19 100644 --- a/maybelle/pinning-service/server.js +++ b/maybelle/pinning-service/server.js @@ -5,6 +5,7 @@ import { createReadStream, statSync, unlinkSync, existsSync } from 'fs'; import { join } from 'path'; import fetch from 'node-fetch'; import FormData from 'form-data'; +import { requireWalletAuth } from './auth.js'; const app = express(); app.use(express.json()); @@ -14,7 +15,7 @@ const PINATA_API_KEY = process.env.PINATA_API_KEY; const PINATA_SECRET_KEY = process.env.PINATA_SECRET_KEY; const IPFS_API_URL = process.env.IPFS_API_URL || 'http://ipfs:5001'; const STAGING_DIR = process.env.STAGING_DIR || '/staging'; -const API_KEY = process.env.PINNING_API_KEY; // Simple auth for the service +const AUTHORIZED_WALLETS = process.env.AUTHORIZED_WALLETS || ''; // File upload handling const upload = multer({ @@ -22,26 +23,13 @@ const upload = multer({ limits: { fileSize: 500 * 1024 * 1024 } // 500MB max }); -// Simple API key auth middleware -function requireAuth(req, res, next) { - const providedKey = req.headers['x-api-key']; - if (!API_KEY) { - console.warn('WARNING: No PINNING_API_KEY set, allowing all requests'); - return next(); - } - if (providedKey !== API_KEY) { - return res.status(401).json({ error: 'Invalid or missing API key' }); - } - next(); -} - // Health check app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Download video from URL (Instagram, YouTube, etc.) and pin to IPFS -app.post('/pin-from-url', requireAuth, async (req, res) => { +app.post('/pin-from-url', requireWalletAuth, async (req, res) => { const { url } = req.body; if (!url) { @@ -96,7 +84,7 @@ app.post('/pin-from-url', requireAuth, async (req, res) => { }); // Upload file directly and pin to IPFS -app.post('/pin-file', requireAuth, upload.single('file'), async (req, res) => { +app.post('/pin-file', requireWalletAuth, upload.single('file'), async (req, res) => { if (!req.file) { return res.status(400).json({ error: 'No file uploaded' }); } @@ -121,7 +109,7 @@ app.post('/pin-file', requireAuth, upload.single('file'), async (req, res) => { }); // Pin an existing CID to local IPFS node (for redundancy) -app.post('/pin-cid', requireAuth, async (req, res) => { +app.post('/pin-cid', requireWalletAuth, async (req, res) => { const { cid } = req.body; if (!cid) { @@ -226,5 +214,6 @@ app.listen(PORT, '0.0.0.0', () => { console.log(`Blue Railroad Pinning Service listening on port ${PORT}`); console.log(`Pinata configured: ${PINATA_API_KEY ? 'yes' : 'NO - uploads will fail'}`); console.log(`IPFS API URL: ${IPFS_API_URL}`); - console.log(`API key auth: ${API_KEY ? 'enabled' : 'DISABLED (open access)'}`); + const walletCount = AUTHORIZED_WALLETS.split(',').filter(w => w.trim()).length; + console.log(`Wallet auth: ${walletCount} authorized wallet(s)`); }); From f9811d406fcbddadad5230079d05c370bea385ec Mon Sep 17 00:00:00 2001 From: Justin Holmes Date: Mon, 12 Jan 2026 17:26:08 +0000 Subject: [PATCH 2/3] Fix Dockerfile: copy auth.js --- maybelle/pinning-service/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maybelle/pinning-service/Dockerfile b/maybelle/pinning-service/Dockerfile index e4a6159..d04fc04 100644 --- a/maybelle/pinning-service/Dockerfile +++ b/maybelle/pinning-service/Dockerfile @@ -19,7 +19,7 @@ COPY package.json ./ RUN npm install --production # Copy application -COPY server.js ./ +COPY server.js auth.js ./ # Create staging directory RUN mkdir -p /staging From 964aedf3ebbeda0b2ca82e894217e4c57258b248 Mon Sep 17 00:00:00 2001 From: Justin Holmes Date: Mon, 12 Jan 2026 23:50:45 +0000 Subject: [PATCH 3/3] Add CORS support to pinning service Allow cross-origin requests from cryptograss domains and hunter dev subdomains so the mint page can call the pinning API from the browser. --- maybelle/pinning-service/package.json | 1 + maybelle/pinning-service/server.js | 33 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/maybelle/pinning-service/package.json b/maybelle/pinning-service/package.json index 461d9be..c2ddd8d 100644 --- a/maybelle/pinning-service/package.json +++ b/maybelle/pinning-service/package.json @@ -9,6 +9,7 @@ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js" }, "dependencies": { + "cors": "^2.8.5", "express": "^4.18.2", "multer": "^1.4.5-lts.1", "node-fetch": "^3.3.2", diff --git a/maybelle/pinning-service/server.js b/maybelle/pinning-service/server.js index 84edc19..7aea675 100644 --- a/maybelle/pinning-service/server.js +++ b/maybelle/pinning-service/server.js @@ -1,4 +1,5 @@ import express from 'express'; +import cors from 'cors'; import multer from 'multer'; import { execSync, spawn } from 'child_process'; import { createReadStream, statSync, unlinkSync, existsSync } from 'fs'; @@ -8,6 +9,38 @@ import FormData from 'form-data'; import { requireWalletAuth } from './auth.js'; const app = express(); + +// CORS configuration - allow requests from cryptograss domains +const allowedOrigins = [ + 'https://cryptograss.live', + 'https://www.cryptograss.live', + /\.hunter\.cryptograss\.live$/, // All hunter dev subdomains + /localhost:\d+$/, +]; + +app.use(cors({ + origin: function(origin, callback) { + // Allow requests with no origin (curl, server-to-server) + if (!origin) return callback(null, true); + + // Check if origin matches any allowed pattern + const isAllowed = allowedOrigins.some(allowed => { + if (allowed instanceof RegExp) { + return allowed.test(origin); + } + return origin === allowed; + }); + + if (isAllowed) { + callback(null, true); + } else { + console.log(`CORS blocked origin: ${origin}`); + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true +})); + app.use(express.json()); const PORT = process.env.PORT || 3001;