From a682cb1cc336fc2a326361fdd657a3d1aacf9b3e Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Thu, 21 May 2026 21:40:37 -0400 Subject: [PATCH] Add SIWE auth endpoints and claim wallet auth panel --- api/auth/nonce.js | 18 ++++++++++ api/auth/verify.js | 65 +++++++++++++++++++++++++++++++++++ public/claim.html | 77 ++++++++++++++++++++++++++++++++++++++++-- tests/api-auth.test.js | 42 +++++++++++++++++++++++ 4 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 api/auth/nonce.js create mode 100644 api/auth/verify.js create mode 100644 tests/api-auth.test.js diff --git a/api/auth/nonce.js b/api/auth/nonce.js new file mode 100644 index 0000000..1d3347d --- /dev/null +++ b/api/auth/nonce.js @@ -0,0 +1,18 @@ +'use strict'; + +const crypto = require('node:crypto'); + +const NONCE_BYTES = 16; + +module.exports = async function handler(req, res) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET'); + return res.status(405).json({ ok: false, status: 'AUTH_FAILED', error: 'Method not allowed. Use GET.' }); + } + + const nonce = crypto.randomBytes(NONCE_BYTES).toString('hex'); + return res.status(200).json({ ok: true, nonce }); +}; diff --git a/api/auth/verify.js b/api/auth/verify.js new file mode 100644 index 0000000..47c6bb1 --- /dev/null +++ b/api/auth/verify.js @@ -0,0 +1,65 @@ +'use strict'; + +const { URL } = require('node:url'); + +const ALLOWED_CHAIN_IDS = new Set((process.env.SIWE_ALLOWED_CHAIN_IDS || '1,8453,11155111').split(',').map((v) => Number(v.trim())).filter(Number.isFinite)); +const ALLOWED_DOMAIN = process.env.SIWE_ALLOWED_DOMAIN || ''; +const ALLOWED_URI = process.env.SIWE_ALLOWED_URI || ''; +const REQUIRED_STATEMENT = 'CommandLayer Claim activation'; + +function getHost(req) { + return String((req.headers && (req.headers['x-forwarded-host'] || req.headers.host)) || '').split(',')[0].trim().toLowerCase(); +} + +module.exports = async function handler(req, res) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ ok: false, status: 'AUTH_FAILED', error: 'Method not allowed. Use POST.' }); + } + + const body = req.body || {}; + const message = typeof body.message === 'string' ? body.message : ''; + const signature = typeof body.signature === 'string' ? body.signature : ''; + if (!message || !signature) { + return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Missing SIWE message or signature.' }); + } + + let SiweMessage; + try { + ({ SiweMessage } = require('siwe')); + } catch { + return res.status(503).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE verification dependency unavailable on server.' }); + } + + try { + const parsed = new SiweMessage(message); + const expectedDomain = ALLOWED_DOMAIN || getHost(req); + if (expectedDomain && String(parsed.domain || '').toLowerCase() !== expectedDomain) { + return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE domain mismatch.' }); + } + + if (ALLOWED_URI && parsed.uri !== ALLOWED_URI) { + return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE URI mismatch.' }); + } + + if (!ALLOWED_CHAIN_IDS.has(Number(parsed.chainId))) { + return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Unsupported SIWE chainId.' }); + } + + if (!String(parsed.statement || '').toLowerCase().includes(REQUIRED_STATEMENT.toLowerCase())) { + return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: 'Invalid SIWE statement for claim activation.' }); + } + + const result = await parsed.verify({ signature, domain: expectedDomain, nonce: parsed.nonce }); + if (!result.success) { + return res.status(401).json({ ok: false, status: 'AUTH_FAILED', error: 'SIWE signature verification failed.' }); + } + + return res.status(200).json({ ok: true, status: 'AUTHENTICATED', address: result.data.address, chainId: Number(result.data.chainId), ens: null }); + } catch (error) { + return res.status(400).json({ ok: false, status: 'AUTH_FAILED', error: error && error.message ? error.message : 'Invalid SIWE payload.' }); + } +}; diff --git a/public/claim.html b/public/claim.html index 16a75b2..caf0307 100644 --- a/public/claim.html +++ b/public/claim.html @@ -428,9 +428,20 @@

Browser-side key generation

Wallet connection is required to write records under your ENS name.
Payment/provisioning is required for CommandLayer-native namespace activation. + +
+
Sign in with Ethereum
+
Authenticate the wallet that will submit this activation request.
+
Your Ethereum wallet authenticates the claim request. Your Ed25519 key signs agent receipts.
+ +
Status: Not authenticated
+
+
+
+
- +
@@ -573,7 +584,8 @@

Payment and provisioning are coming next.

capMode:'packs', selectedPack:null, cherryVerbs:[], pubKeyB64:'', privKeyB64:'', kid:'', keyGenerated:false, keyDownloaded:false, keyAcked:false, - _cardJson:'' + _cardJson:'', + authenticatedAddress:'', authStatus:'NOT_AUTHENTICATED', authChainId:null }; // ── HELPERS ─────────────────────────────────────────────────────────────────── @@ -665,6 +677,7 @@

Payment and provisioning are coming next.

document.getElementById('mode-'+id).classList.toggle('selected', id===m); }); updateNamePreview(); + updateSiweModeHint(); } function updateNamePreview() { @@ -685,11 +698,13 @@

Payment and provisioning are coming next.

if (!state.activationMode) { alert('Please choose an activation mode.'); return; } // default to CL mode if none selected if (!document.querySelector('.mode-card.selected')) selectMode('cl'); +updateSiweModeHint(); goToStep(3); } // Auto-select CL mode on load selectMode('cl'); +updateSiweModeHint(); // ── STEP 3: CAPABILITIES ────────────────────────────────────────────────────── function setCapMode(m) { @@ -754,6 +769,63 @@

Payment and provisioning are coming next.

goToStep(4); } + + +function shortAddr(addr){return addr?`${addr.slice(0,6)}...${addr.slice(-4)}`:'';} + +function updateSiweModeHint(){ + const el=document.getElementById('siweModeHint'); + if(!el) return; + if(state.activationMode==='own' || state.activationMode==='single'){ + el.textContent='ENS ownership verification comes after wallet authentication.'; + }else{ + el.textContent='CommandLayer namespace activation requires wallet authentication before submission.'; + } +} + +async function signInWithEthereum(){ + if(!window.ethereum){ alert('No Ethereum wallet detected. Install a wallet extension first.'); return; } + updateSiweModeHint(); + const statusEl=document.getElementById('siweStatus'); + const walletEl=document.getElementById('siweWallet'); + statusEl.textContent='Status: Requesting signature...'; + const [address]=await window.ethereum.request({ method:'eth_requestAccounts' }); + const nonceRes=await fetch('/api/auth/nonce',{method:'GET',headers:{'Accept':'application/json'}}); + const nonceData=await nonceRes.json(); + if(!nonceData.ok){ throw new Error('Nonce request failed'); } + const domain=window.location.host; + const uri=window.location.origin; + const chainHex=await window.ethereum.request({ method:'eth_chainId' }); + const chainId=parseInt(chainHex,16); + const issuedAt=new Date().toISOString(); + const msg=`${domain} wants you to sign in with your Ethereum account: +${address} + +CommandLayer Claim activation + +URI: ${uri} +Version: 1 +Chain ID: ${chainId} +Nonce: ${nonceData.nonce} +Issued At: ${issuedAt}`; + const signature=await window.ethereum.request({method:'personal_sign',params:[msg,address]}); + const verifyRes=await fetch('/api/auth/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:msg,signature})}); + const verify=await verifyRes.json(); + if(!verify.ok){ throw new Error(verify.error||'Authentication failed'); } + state.authenticatedAddress=verify.address; state.authStatus=verify.status; state.authChainId=verify.chainId; + statusEl.textContent='Status: Authenticated'; + walletEl.textContent=`Connected wallet: ${shortAddr(verify.address)}`; +} + +function goStep5Auth(){ + updateSiweModeHint(); + if(state.activationMode==='cl' && !state.authenticatedAddress){ + alert('Sign-In with Ethereum is required before submitting CommandLayer activation request.'); + return; + } + goToStep(6); +} + // ── STEP 4: KEY GEN ─────────────────────────────────────────────────────────── async function generateKey() { const errorBox = document.getElementById('keyError'); @@ -1073,6 +1145,7 @@

Payment and provisioning are coming next.

const err=document.getElementById('keyError'); if(err){err.classList.remove('show');err.textContent='';} document.getElementById('namePreview').classList.remove('show'); selectMode('cl'); +updateSiweModeHint(); goToStep(1); } diff --git a/tests/api-auth.test.js b/tests/api-auth.test.js new file mode 100644 index 0000000..04ebc07 --- /dev/null +++ b/tests/api-auth.test.js @@ -0,0 +1,42 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const nonceHandler = require('../api/auth/nonce'); +const verifyHandler = require('../api/auth/verify'); + +function makeRes() { + return { + statusCode: 200, + headers: {}, + body: null, + setHeader(name, value) { this.headers[name.toLowerCase()] = value; }, + status(code) { this.statusCode = code; return this; }, + json(payload) { this.body = payload; return this; }, + }; +} + +test('GET /api/auth/nonce returns nonce and randomness', async () => { + const r1 = makeRes(); const r2 = makeRes(); + await nonceHandler({ method: 'GET', headers: {} }, r1); + await nonceHandler({ method: 'GET', headers: {} }, r2); + assert.equal(r1.statusCode, 200); + assert.equal(r1.body.ok, true); + assert.match(r1.body.nonce, /^[a-f0-9]{32,}$/); + assert.notEqual(r1.body.nonce, r2.body.nonce); +}); + +test('POST /api/auth/verify rejects missing signature', async () => { + const res = makeRes(); + await verifyHandler({ method: 'POST', body: { message: 'x' }, headers: { host: 'localhost:3000' } }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.ok, false); +}); + +test('POST /api/auth/verify rejects malformed message/signature', async () => { + const res = makeRes(); + await verifyHandler({ method: 'POST', body: { message: 'invalid', signature: '0xdeadbeef' }, headers: { host: 'localhost:3000' } }, res); + assert.equal(res.body.ok, false); + assert.equal(res.body.status, 'AUTH_FAILED'); +});