Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions api/auth/nonce.js
Original file line number Diff line number Diff line change
@@ -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 });
};
65 changes: 65 additions & 0 deletions api/auth/verify.js
Original file line number Diff line number Diff line change
@@ -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.' });
}
};
77 changes: 75 additions & 2 deletions public/claim.html
Original file line number Diff line number Diff line change
Expand Up @@ -428,9 +428,20 @@ <h3>Browser-side key generation</h3>
Wallet connection is required to write records under your ENS name.<br>
Payment/provisioning is required for CommandLayer-native namespace activation.
</div>

<div style="margin-top:16px;padding:14px;border-radius:12px;border:1px solid var(--border);background:#fff">
<div style="font-weight:700;margin-bottom:6px">Sign in with Ethereum</div>
<div style="font-size:13px;color:var(--text-2);margin-bottom:10px">Authenticate the wallet that will submit this activation request.</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Your Ethereum wallet authenticates the claim request. Your Ed25519 key signs agent receipts.</div>
<button class="btn btn-secondary" id="siweAuthBtn" onclick="signInWithEthereum()">Sign-In with Ethereum</button>
<div id="siweStatus" style="font-size:13px;color:var(--text-2);margin-top:10px">Status: Not authenticated</div>
<div id="siweWallet" style="font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:4px"></div>
<div id="siweModeHint" style="font-size:12px;color:var(--muted);margin-top:8px"></div>
</div>

<div class="btn-row">
<button class="btn btn-ghost" onclick="goToStep(4)">← Back</button>
<button class="btn btn-primary" onclick="goToStep(6)">Continue →</button>
<button class="btn btn-primary" onclick="goStep5Auth()">Continue →</button>
</div>
</div>

Expand Down Expand Up @@ -573,7 +584,8 @@ <h3>Payment and provisioning are coming next.</h3>
capMode:'packs', selectedPack:null, cherryVerbs:[],
pubKeyB64:'', privKeyB64:'', kid:'',
keyGenerated:false, keyDownloaded:false, keyAcked:false,
_cardJson:''
_cardJson:'',
authenticatedAddress:'', authStatus:'NOT_AUTHENTICATED', authChainId:null
};

// ── HELPERS ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -665,6 +677,7 @@ <h3>Payment and provisioning are coming next.</h3>
document.getElementById('mode-'+id).classList.toggle('selected', id===m);
});
updateNamePreview();
updateSiweModeHint();
}

function updateNamePreview() {
Expand All @@ -685,11 +698,13 @@ <h3>Payment and provisioning are coming next.</h3>
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) {
Expand Down Expand Up @@ -754,6 +769,63 @@ <h3>Payment and provisioning are coming next.</h3>
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');
Expand Down Expand Up @@ -1073,6 +1145,7 @@ <h3>Payment and provisioning are coming next.</h3>
const err=document.getElementById('keyError'); if(err){err.classList.remove('show');err.textContent='';}
document.getElementById('namePreview').classList.remove('show');
selectMode('cl');
updateSiweModeHint();
goToStep(1);
}

Expand Down
42 changes: 42 additions & 0 deletions tests/api-auth.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
Loading