diff --git a/aesel/src/walkieware-thread.mjs b/aesel/src/walkieware-thread.mjs index 01f9d67a45..3705da183b 100644 --- a/aesel/src/walkieware-thread.mjs +++ b/aesel/src/walkieware-thread.mjs @@ -1,5 +1,12 @@ // Stable local identity; the server atomically reserves its pronounceable code. const ledgerText = ledger => ledger?JSON.stringify({format:ledger.format,head:ledger.head,versions:ledger.versions.map(v=>({id:v.id,parent:v.parent,source:v.source,request:v.request??null,createdAt:v.createdAt||'',layers:Number.isInteger(v.layers)?v.layers:0}))}):'null'; +export async function verifyThreadRevision(command,state) { + const before=state();if(before.busy)throw Error('Device busy'); + const digest=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(before.source)); + const hash=[...new Uint8Array(digest)].map(b=>b.toString(16).padStart(2,'0')).join(''); + const after=state(); + if(after.busy||after.head!==before.head||after.source!==before.source||command.baseVersion!==after.head||command.baseHash!==hash)throw Error('Version changed; inspect before editing'); +} export function threadIdentity(storage,key,uuid=()=>crypto.randomUUID()) { const saved=storage.getItem(key+'-thread'); if(saved){const value=JSON.parse(saved);if(typeof value.id!=='string')throw Error('Invalid thread identity');return value;} @@ -32,7 +39,7 @@ export class WalkiewareThread { this.storage.setItem(this.key+'-cloud-revision',String(this.revision));this.storage.setItem(this.key+'-cloud-ledger',this.last);this.sync(); } if(m.type==='conflict'){this.ready=false;this.sending=false;this.onStatus(this.identity.code,'History conflict');} - if(m.type==='error'){this.sending=false;this.onStatus(this.identity.code,m.error);} + if(m.type==='error'){this.ready=false;this.sending=false;this.onStatus(this.identity.code,m.error);} if(m.type==='command') { try{if(!this.ready||this.sending)throw Error('Thread is not synchronized');const result=await this.onCommand(m);this.sync();await this.flush();this.send({type:'result',id:m.id,...result});} catch(error){this.send({type:'result',id:m.id,ok:false,error:error.message});} diff --git a/lith/walkieware-socket.mjs b/lith/walkieware-socket.mjs index 4812ff231b..e55856aa3a 100644 --- a/lith/walkieware-socket.mjs +++ b/lith/walkieware-socket.mjs @@ -54,10 +54,10 @@ export function attachWalkiewareSocket(server,{authenticate,store,authMs=5000,li const id=typeof m.id==='string'&&/^[a-zA-Z0-9-]{1,80}$/.test(m.id)?m.id:randomUUID(); if(!room.device){send(ws,{type:'result',id,ok:false,error:'Device offline; saved versions remain available'});return;} if(room.commands.size||room.state?.busy){send(ws,{type:'result',id,ok:false,error:'Device busy'});return;} - if(!['ask','undo'].includes(m.action)||!Number.isSafeInteger(m.baseVersion)||typeof m.baseHash!=='string'||(m.action==='ask'&&(typeof m.text!=='string'||!m.text.trim()||m.text.length>20000)))throw Error('Invalid command'); + if(!['ask','undo','edit'].includes(m.action)||!Number.isSafeInteger(m.baseVersion)||typeof m.baseHash!=='string'||(m.action==='ask'&&(typeof m.text!=='string'||!m.text.trim()||m.text.length>20000))||(m.action==='edit'&&(typeof m.source!=='string'||Buffer.byteLength(m.source)>500000||!m.source.trim())))throw Error('Invalid command'); const timer=setTimeout(()=>{room.commands.delete(id);send(ws,{type:'result',id,ok:false,error:'Timed out; inspect history before retrying'});},180000);timer.unref?.(); room.commands.set(id,{ws,timer}); - send(room.device,{type:'command',id,action:m.action,text:m.text,baseVersion:m.baseVersion,baseHash:m.baseHash}); + send(room.device,{type:'command',id,action:m.action,text:m.text,source:m.source,baseVersion:m.baseVersion,baseHash:m.baseHash}); send(ws,{type:'accepted',id});return; } if(m.type==='result'&&role==='device') { diff --git a/lith/walkieware-socket.test.mjs b/lith/walkieware-socket.test.mjs index 85bb326472..dbc3620c31 100644 --- a/lith/walkieware-socket.test.mjs +++ b/lith/walkieware-socket.test.mjs @@ -6,9 +6,17 @@ import {WebSocket} from 'ws'; import {attachWalkiewareSocket} from './walkieware-socket.mjs'; import {attachMusicalSocket} from './musical-socket.mjs'; import {mongoWalkiewareStore,validateLedger,sourceHash} from '../system/backend/walkieware.mjs'; -import {WalkiewareThread,threadIdentity} from '../aesel/src/walkieware-thread.mjs'; +import {WalkiewareThread,threadIdentity,verifyThreadRevision} from '../aesel/src/walkieware-thread.mjs'; const id='11111111-1111-4111-8111-111111111111'; const ledger={format:1,head:0,versions:[{id:0,parent:null,source:'export function paint({wipe}){wipe(0);}',request:null,createdAt:'today',layers:0}]}; +test('remote edits reject stale versions, mismatched source and a local ask starting during hashing',async()=>{ + const source=ledger.versions[0].source,state={busy:false,head:0,source}; + const command={baseVersion:0,baseHash:sourceHash(source)}; + await verifyThreadRevision(command,()=>state); + await assert.rejects(verifyThreadRevision({...command,baseVersion:1},()=>state),/Version changed/); + await assert.rejects(verifyThreadRevision({...command,baseHash:'wrong'},()=>state),/Version changed/); + let n=0;await assert.rejects(verifyThreadRevision(command,()=>({...state,busy:++n>1})),/Version changed/); +}); function memoryCollection(){ const docs=new Map(); const find=query=>[...docs.values()].find(row=>Object.entries(query).every(([k,v])=>row[k]===v)); diff --git a/slab/bin/ww.mjs b/slab/bin/ww.mjs index ca40af8008..757a5c0174 100644 --- a/slab/bin/ww.mjs +++ b/slab/bin/ww.mjs @@ -3,8 +3,11 @@ import {ACSession,USER_AGENT} from '../../aesel/src/ac-session.mjs'; import {WebSocket} from 'ws'; import {createHash,randomUUID} from 'node:crypto'; +import {readFileSync} from 'node:fs'; const [command='list',code,...words]=process.argv.slice(2); -if(!['list','inspect','watch','ask','undo'].includes(command))throw Error('Usage: ww.mjs list | inspect CODE | watch CODE | ask CODE WORDS | undo CODE'); +if(!['list','inspect','watch','ask','undo','edit'].includes(command))throw Error('Usage: ww.mjs list | inspect CODE | watch CODE | ask CODE WORDS | undo CODE | edit CODE FILE BASE_VERSION BASE_HASH'); +const replacement=command==='edit'?readFileSync(words[0],'utf8'):null; +if(command==='edit'&&(!/^\d+$/.test(words[1]||'')||!/^[a-f0-9]{64}$/.test(words[2]||'')))throw Error('An edit requires the version and SHA-256 from your inspected source'); const session=new ACSession(),token=await session.token(); if(!token)throw Error('Sign in with ac-login first'); const origin=process.env.WALKIE_ORIGIN||'https://aesthetic.computer'; @@ -23,7 +26,7 @@ if(command==='list'||command==='inspect') { if(!m.online){console.error('Device offline');process.exitCode=1;ws.close();return;} const v=m.thread.ledger?.versions.find(v=>v.id===m.thread.ledger.head); if(!v){console.error('No synchronized version');process.exitCode=1;ws.close();return;} - ws.send(JSON.stringify({type:'command',id:randomUUID(),action:command,text:words.join(' '),baseVersion:v.id,baseHash:createHash('sha256').update(v.source).digest('hex')})); + ws.send(JSON.stringify({type:'command',id:randomUUID(),action:command,text:command==='edit'?'Remote source edit':words.join(' '),source:replacement,baseVersion:command==='edit'?Number(words[1]):v.id,baseHash:command==='edit'?words[2]:createHash('sha256').update(v.source).digest('hex')})); } if(['result','error','accepted'].includes(m.type))console.log(JSON.stringify(m)); if(m.type==='result'||m.type==='error'){if(m.type==='error'||!m.ok)process.exitCode=1;ws.close();}