Implement SSH authorized key management

This commit is contained in:
2026-09-08 16:37:47 +02:00
parent 22a7c7b0a5
commit 42f6423d4e
19 changed files with 930 additions and 83 deletions
+137 -1
View File
@@ -13,7 +13,7 @@ const deferred = () => { let resolve; const promise = new Promise(r => { resolve
const tick = async () => { for (let i = 0; i < 6; ++i) await new Promise(r => setImmediate(r)); };
function browser({onlyLoader = false, withLoader = false, role = 'user', username = '<img>'} = {}) {
const nodes = {}, events = {}, calls = [], redirects = [], timers = new Map(), sockets = [], terminals = [];
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': []};
const queues = {'/api/session': [], '/api/status': [], '/api/ws-ticket': [], '/api/admin/ws-ticket': [], '/api/logout': [], '/api/settings/serial': [], '/api/settings/serial-operation': [], '/api/settings/accounts': [], '/api/settings/account-operation': [], '/api/settings/accounts/generate-password': [], '/api/settings/accounts/keys': []};
const fits = [];
let serial = 0, now = Date.now();
class Clock extends Date { static now() { return now; } }
@@ -789,8 +789,144 @@ async function test(name, fn) { await fn(); ++passed; console.log('PASS JS:', na
b.click('select-settings'); await tick(); b.click('settings-accounts'); await tick();
return b;
}
const keysPath = '/api/settings/accounts/keys';
const fingerprint = 'SHA256:' + 'a'.repeat(43);
const keysReply = (extra = {}) => json({username:'carol',user_id:7,auth_generation:2,keys:[{index:0,type:'ssh-ed25519',fingerprint}],...extra});
async function keyBrowser() {
const b = await accountsBrowser(); b.nodes['account-target'].value='1'; b.nodes['account-target'].change();
b.queues[keysPath].push(keysReply()); b.click('account-keys-refresh'); await tick(); return b;
}
const accountPath = '/api/settings/account-operation';
const accountReply = (id, state, action = 'role') => json({id, state, action});
await test('Key list exact protected identity POST, safe fingerprints and socket/lease isolation', async () => {
const b=await keyBrowser(), p=b.calls.find(c=>c.url===keysPath);
assert.deepEqual(JSON.parse(p.body),{username:'carol',user_id:7,auth_generation:2});
assert.equal(p.headers['X-CSRF-Token'],token); assert.equal(p.headers['Content-Type'],'application/json');
assert.match(b.nodes['account-keys-list'].textContent,/0: ssh-ed25519 SHA256:/);
assert.ok(!b.nodes['account-key-delete'].disabled); assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
const u=await connected(); u.click('account-keys-refresh'); u.click('account-key-add'); await tick(); assert.ok(!u.calls.some(c=>c.url===keysPath || c.url===accountPath));
});
await test('ECDSA P-256 lists and imports; key read timeout and 401 use existing session isolation', async () => {
const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:[{index:0,type:'ecdsa-sha2-nistp256',fingerprint}]})); b.click('account-keys-refresh'); await tick(); assert.match(b.nodes['account-keys-list'].textContent,/ecdsa-sha2-nistp256/);
b.nodes['account-public-key'].value='ecdsa-sha2-nistp256 AAAA comment'; b.queues[accountPath].push(accountReply(34,'pending','key-add')); b.click('account-key-add'); await tick(); assert.equal(JSON.parse(b.calls.find(c=>c.url===accountPath).body).public_key,'ecdsa-sha2-nistp256 AAAA comment');
const t=await keyBrowser(); t.queues[keysPath].push(o=>new Promise((_,reject)=>o.signal.addEventListener('abort',()=>reject(new Error('timeout'))))); t.click('account-keys-refresh'); await tick(); t.fire(15000); await tick(); assert.ok(!t.nodes['account-keys-refresh'].disabled); assert.ok(t.nodes['account-key-delete'].disabled); assert.ok(t.sockets.every(s=>!s.closed));
t.queues[keysPath].push(failure(401)); t.click('account-keys-refresh'); await tick(); assert.deepEqual(t.redirects,['/login']); assert.ok(t.sockets.every(s=>s.closed)); assert.equal(t.nodes['account-keys-list'].textContent,'');
});
await test('Key import/delete/clear confirm exact body, single POST and refresh new generation keys', async () => {
for(const action of ['key-add','key-delete','key-clear']) {
const b=await keyBrowser(); const publicKey='ssh-ed25519 AAAA comment'; b.nodes['account-public-key'].value=publicKey;
b.window.confirm=()=>false; b.click('account-'+action); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(!b.calls.some(c=>c.url===accountPath));
b.nodes['account-public-key'].value=publicKey; let confirmation; b.window.confirm=m=>{confirmation=m; return true;};
b.queues[accountPath].push(accountReply(30,'pending',action)); b.click('account-'+action); await tick();
assert.equal(b.nodes['account-public-key'].value,''); assert.match(confirmation,/carol/); if(action==='key-delete') assert.ok(confirmation.includes(fingerprint));
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1);
assert.deepEqual(JSON.parse(posts[0].body),{action,username:'carol',user_id:7,auth_generation:2,...(action==='key-add'?{public_key:publicKey}:action==='key-delete'?{key_index:0}:{})});
b.queues[accountPath].push(accountReply(30,'ok',action));
b.queues['/api/settings/accounts'].push(json({users:[{username:'carol',role:'user',user_id:7,auth_generation:3}]})); b.queues[keysPath].push(keysReply({auth_generation:3}));
b.fire(1000); await tick(); assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/);
assert.equal(JSON.parse(b.calls.filter(c=>c.url===keysPath).at(-1).body).auth_generation,3); assert.ok(!b.nodes['account-key-delete'].disabled);
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
const slotKey = index => ({index,type:'ssh-ed25519',fingerprint:'SHA256:' + String.fromCharCode(97 + index).repeat(43)});
function assertKeySlots(b, indices) {
assert.equal(b.nodes['account-keys-list'].textContent, indices.map(index => `${index}: ssh-ed25519 ${slotKey(index).fingerprint}`).join('\n'));
assert.equal(b.nodes['account-key-index'].value, String(indices[0]));
for(let index=0;index<3;++index) {
const option=b.nodes['key-option-'+index], present=indices.includes(index);
assert.equal(option.value,String(index)); assert.equal(option.hidden,!present); assert.equal(option.disabled,!present);
assert.equal(option.textContent,present?`${index}: ${slotKey(index).fingerprint}`:'');
}
assert.ok(!b.nodes['account-key-delete'].disabled && !b.nodes['account-key-clear'].disabled);
}
for(const indices of [[1],[0,2]]) await test(`Sparse key slots [${indices}] render and delete by index, not array position`, async () => {
for(const selected of indices) {
const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:indices.map(slotKey)}));
b.click('account-keys-refresh'); await tick(); assertKeySlots(b,indices);
let confirmation; b.window.confirm=m=>{confirmation=m;return true;};
b.nodes['account-key-index'].value=String(selected); b.nodes['account-key-index'].change();
b.queues[accountPath].push(accountReply(35,'pending','key-delete')); b.click('account-key-delete'); await tick();
assert.ok(confirmation.includes(slotKey(selected).fingerprint));
for(const other of indices.filter(index=>index!==selected)) assert.ok(!confirmation.includes(slotKey(other).fingerprint));
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,1);
assert.deepEqual(JSON.parse(posts[0].body),{action:'key-delete',username:'carol',user_id:7,auth_generation:2,key_index:selected});
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Key deletion automatically refreshes sparse survivors and uses their new identity for the next deletion', async () => {
for(const [before,removed,after] of [[[0,1],0,[1]],[[0,1,2],1,[0,2]]]) {
const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:before.map(slotKey)}));
b.click('account-keys-refresh'); await tick(); b.nodes['account-key-index'].value=String(removed);
b.queues[accountPath].push(accountReply(36,'pending','key-delete')); b.click('account-key-delete'); await tick();
const reads=b.calls.filter(c=>c.url===keysPath).length;
b.queues[accountPath].push(accountReply(36,'ok','key-delete'));
b.queues['/api/settings/accounts'].push(json({users:[{username:'carol',role:'user',user_id:7,auth_generation:3}]}));
b.queues[keysPath].push(keysReply({auth_generation:3,keys:after.map(slotKey)}));
b.fire(1000); await tick(); assertKeySlots(b,after);
assert.match(b.nodes['account-operation-detail'].textContent,/completed and saved/);
const keyReads=b.calls.filter(c=>c.url===keysPath); assert.equal(keyReads.length,reads+1);
assert.deepEqual(JSON.parse(keyReads.at(-1).body),{username:'carol',user_id:7,auth_generation:3});
b.nodes['account-key-index'].value=String(removed); b.click('account-key-delete'); await tick();
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1);
const selected=after.at(-1); let confirmation; b.window.confirm=m=>{confirmation=m;return true;};
b.nodes['account-key-index'].value=String(selected); b.queues[accountPath].push(accountReply(37,'pending','key-delete'));
b.click('account-key-delete'); await tick(); assert.ok(confirmation.includes(slotKey(selected).fingerprint));
const posts=b.calls.filter(c=>c.url===accountPath && c.method==='POST'); assert.equal(posts.length,2);
assert.deepEqual(JSON.parse(posts[1].body),{action:'key-delete',username:'carol',user_id:7,auth_generation:3,key_index:selected});
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Duplicate and out-of-range key slots reject the whole list and cannot authorize deletion', async () => {
for(const indices of [[1,1],[0,2,2],[-1],[3],[0,3],[1.5],['1']]) {
const b=await keyBrowser(); b.queues[keysPath].push(keysReply({keys:indices.map(index=>({...slotKey(0),index}))}));
b.click('account-keys-refresh'); await tick();
assert.equal(b.nodes['account-keys-list'].textContent,''); assert.match(b.nodes['account-keys-detail'].textContent,/unavailable or invalid/);
assert.ok(b.nodes['account-key-delete'].disabled && b.nodes['account-key-clear'].disabled && b.nodes['account-key-index'].disabled);
let confirmations=0; b.window.confirm=()=>{++confirmations;return true;};
b.nodes['account-key-index'].value='0'; b.click('account-key-delete'); b.click('account-key-clear'); await tick();
assert.equal(confirmations,0); assert.ok(!b.calls.some(c=>c.url===accountPath));
assert.equal(b.calls.filter(c=>c.url===keysPath).length,2);
assert.ok(![...b.timers.values()].some(t=>t.ms===1000));
assert.ok(b.sockets.every(s=>!s.closed && !s.sent.length));
}
});
await test('Key list rejects stale identities, invalid schema and optional endpoint failures without retry', async () => {
for(const response of [failure(409),failure(404),failure(503),keysReply({user_id:8}),keysReply({auth_generation:3}),keysReply({keys:[{index:3,type:'ssh-ed25519',fingerprint}]}),keysReply({keys:[{index:0,type:'ssh-ed25519',fingerprint:'<img>'}]}),keysReply({keys:Array(4).fill({})}),new Response(' '.repeat(769))]) {
const b=await keyBrowser(); b.queues[keysPath].push(response); b.click('account-keys-refresh'); await tick();
assert.equal(b.nodes['account-keys-list'].textContent,''); assert.ok(b.nodes['account-key-delete'].disabled && b.nodes['account-key-clear'].disabled);
assert.match(b.nodes['account-keys-detail'].textContent,/stale|unavailable/); assert.equal(b.calls.filter(c=>c.url===keysPath).length,2);
b.click('account-key-delete'); await tick(); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
});
await test('Pasted keys clear on contexts and late list headers/body cannot change new target', async () => {
for(const streamed of [false,true]) for(const mode of ['target','view','domain','refresh','pagehide','logout']) {
const b=await keyBrowser(), d=deferred(); let stream;
b.queues[keysPath].push(streamed?new Response(new ReadableStream({start(c){stream=c;}})):d.promise);
b.click('account-keys-refresh'); await tick(); const p=b.calls.filter(c=>c.url===keysPath).at(-1); b.nodes['account-public-key'].value='PASTED';
if(mode==='target') { b.nodes['account-target'].value='0'; b.nodes['account-target'].change(); }
if(mode==='view') b.click('select-serial'); if(mode==='domain') b.click('settings-serial'); if(mode==='refresh') b.click('refresh-accounts'); if(mode==='pagehide') b.emit('pagehide'); if(mode==='logout') b.click('sign-out');
await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(p.signal.aborted);
if(streamed) { try {stream.enqueue(new TextEncoder().encode(await keysReply().text())); stream.close();} catch {} } else d.resolve(failure(401));
await tick(); assert.equal(b.nodes['account-keys-list'].textContent,''); if(mode!=='logout') assert.deepEqual(b.redirects,[]);
}
});
await test('Key UTF-8 and JSON bounds reject private/multiline/oversize; cancellation clears paste', async () => {
for(const value of ['-----BEGIN OPENSSH PRIVATE KEY-----','ssh-ed25519 AAAA\nssh-ed25519 BBBB','ssh-ed25519 AAAA '+ 'é'.repeat(185),'ssh-ed25519 AAAA '+ 'x'.repeat(369),'ssh-ed25519 AAAA '+ '\\'.repeat(367)]) {
const b=await keyBrowser(); b.nodes['account-public-key'].value=value; b.click('account-key-add'); await tick(); assert.equal(b.nodes['account-public-key'].value,''); assert.ok(!b.calls.some(c=>c.url===accountPath));
}
const b=await keyBrowser(); b.nodes['account-public-key'].value='ssh-ed25519 AAAA '+ 'x'.repeat(367); b.queues[accountPath].push(accountReply(31,'pending','key-add')); b.click('account-key-add'); await tick(); assert.equal(Buffer.byteLength(JSON.parse(b.calls.find(c=>c.url===accountPath).body).public_key),384);
});
await test('Key outcomes duplicate/full/stale/failed refresh, bounded polls and self 401 uncertainty', async () => {
for(const state of ['duplicate','full','stale','failed']) {
const b=await keyBrowser(); b.queues[accountPath].push(accountReply(32,state,'key-add')); b.queues[keysPath].push(keysReply()); b.click('account-result'); await tick();
assert.doesNotMatch(b.nodes['account-operation-detail'].textContent,/Username already|Account capacity/); assert.equal(b.calls.filter(c=>c.url===keysPath).length,2);
}
const b=await keyBrowser(); b.queues[accountPath].push(accountReply(33,'pending','key-clear')); b.click('account-key-clear'); await tick();
for(let i=0;i<10;++i) { b.queues[accountPath].push(accountReply(33,'pending','key-clear')); b.elapse(1000); b.fire(1000); await tick(); }
assert.equal(b.calls.filter(c=>c.url===accountPath && c.method==='POST').length,1); assert.match(b.nodes['account-operation-detail'].textContent,/stopped/);
const s=await accountsBrowser(); s.nodes['account-public-key'].value='ssh-ed25519 AAAA'; let warning; s.window.confirm=m=>{warning=m;return true;}; s.queues[accountPath].push(failure(401)); s.click('account-key-add'); await tick();
assert.match(warning,/ALL.*web\/SSH.*401.*NOT proof/); assert.deepEqual(s.redirects,['/login']); assert.ok(s.sockets.every(s=>s.closed)); assert.doesNotMatch(s.nodes['account-operation-detail'].textContent,/completed/);
});
await test('Accounts list is admin-only, secret-free schema and navigation preserves both sockets', async () => {
const u = await connected(); u.click('settings-accounts'); await tick();
assert.ok(!u.calls.some(c => c.url === '/api/settings/accounts'));
+4
View File
@@ -84,6 +84,10 @@ esp_err_t httpd_resp_send(httpd_req_t *, const char *, ssize_t);
assert 'id="account-password-saved" type="checkbox"' in rendered['html']
assert 'not applied yet' in rendered['html'] and 'no retrieval' in rendered['html']
assert 'JavaScript cannot securely zero strings' in rendered['html']
assert 'id="account-public-key" maxlength="384" autocomplete="off" spellcheck="false"' in rendered['html']
for action in ('add', 'delete', 'clear'):
assert f'id="account-key-{action}"' in rendered['html']
assert 'no private-key upload, export or SSH host management' in rendered['html']
for forbidden in ('localStorage', 'sessionStorage', 'document.cookie', 'console.log', 'innerHTML', 'Authorization', 'clipboard', 'pushState', 'replaceState'):
assert forbidden not in rendered['script'] + rendered['loader'], forbidden
(tmp / 'rendered.json').write_text(json.dumps(rendered))