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');
+});