/** BringPass 0.3 experimental reference — Node.js, no HTTP or device transport. * Trust, authority, storage and human approval are LOCAL ports, not caller claims. * Single-threaded/in-memory demo. See docs/protocol.md before adapting it. */ import {generateKeyPairSync,createPublicKey,createPrivateKey,sign as edSign, verify as edVerify,createHash,randomBytes} from 'node:crypto'; export const VERSION='0.3', SUITE='Ed25519'; export const OPS=Object.freeze(['REGISTER','USE','REVOKE','RETRACT']); const DOMAIN=Buffer.from('BringPass/reference/0.3\0','utf8'); export function check(ok,why) {if(!ok) throw new Error(why);} const hex=(s,n)=>typeof s==='string'&&s.length===n*2&&/^[0-9a-f]+$/.test(s); export function encode(fields) { check(Array.isArray(fields)&&fields.length>0&&fields.length<=32,'field count'); const count=Buffer.alloc(2);count.writeUInt16BE(fields.length); const chunks=[DOMAIN,count]; for(const value of fields){ check(typeof value==='string'&&!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?generateKeyPairSync('ed25519').privateKey; export function signed(key,fields){ return Object.freeze({fields:Object.freeze([...fields]),signature:edSign(null,encode(fields),key).toString('hex')}); } export function parseMessage(obj){ check(obj&&Object.keys(obj).sort().join(',')==='fields,signature'&&hex(obj.signature,64),'envelope');encode(obj.fields); return Object.freeze({fields:Object.freeze([...obj.fields]),signature:obj.signature}); } export function verify(key,message){check(hex(message.signature,64)&&edVerify(null,encode(message.fields),pub(key),Buffer.from(message.signature,'hex')),'signature');} export function digest(message){return createHash('sha256').update(encode(message.fields)).update(Buffer.from(message.signature,'hex')).digest('hex');} function header(message,kind,n){const f=message.fields;check(f.length===n&&f[0]===kind&&f[1]===VERSION&&f[2]===SUITE,'version/suite/kind');check(OPS.includes(f[3])&&hex(f[4],16),'operation/transaction');return f;} const equal=(a,b)=>a.length===b.length&&a.every((x,i)=>x===b[i]); const targetOK=(op,target)=>['REGISTER','USE'].includes(op)?target==='':op==='REVOKE'?hex(target,16):target==='*'; /** Authority = {account, operations, context} from a trusted provider adapter. * Do not deserialize it from claimant data. It is NOT an authentication protocol. */ export class Provider { constructor(provider,key=freshKey(),clock=()=>Math.floor(Date.now()/1000)){ Object.assign(this,{provider,key,clock});this.relations=new Map();this.pending=new Map();this.completed=new Map(); } challenge(op,rid,target,authority){ check(OPS.includes(op)&&authority.operations.includes(op),'management authority required'); check(authority.context&&(authority.account||op==='USE'),'trusted context / management account'); check(hex(rid,16)||(op==='USE'&&rid===''),'relationship');check(targetOK(op,target),'target'); const tx=randomBytes(16).toString('hex'); const q=signed(this.key,['CHALLENGE',VERSION,SUITE,op,tx,this.provider,rid,target,randomBytes(32).toString('hex'),String(this.clock()+120),authority.context]); this.pending.set(tx,{q,authority});return q; } finish(proof,trustedContext){ const f=header(proof,'PROOF',11); const [,,,op,tx,provider,rid,cid,target,qhash,publicKey]=f; check(provider===this.provider&&hex(rid,16)&&hex(cid,16),'scope');const phash=digest(proof); if(this.completed.has(tx)){ const old=this.completed.get(tx);check(old.hash===phash&&old.context===trustedContext,'retry differs'); return {receipt:old.receipt,newlyCommitted:false}; } check(this.pending.has(tx),'unknown transaction');const {q,authority}=this.pending.get(tx),c=q.fields; check(c[3]===op&&c[5]===provider&&c[7]===target&&digest(q)===qhash,'challenge binding'); check(c[6]===rid||(op==='USE'&&c[6]===''),'relationship binding'); check(c[10]===trustedContext&&authority.context===trustedContext,'session/desk binding'); check(this.clock()<=Number(c[9])&&authority.operations.includes(op),'expired/unauthorised'); let relation=this.relations.get(rid),state; if(relation)check(relation.state==='active'&&((op==='USE'&&!authority.account)||relation.account===authority.account),'relationship disabled/wrong account'); if(op==='REGISTER'){ check(hex(publicKey,32),'new key required');check(!relation||!relation.keys.has(cid),'credential ID already used');verify(publicKey,proof); if(!relation){relation={account:authority.account,state:'active',revision:0,entitled:true,keys:new Map()};this.relations.set(rid,relation);} relation.keys.set(cid,{publicKey,state:'active'});relation.revision++;state='registered'; }else{ check(publicKey===''&&relation,'do not supply a replacement verification key');const actor=relation.keys.get(cid); check(actor?.state==='active','actor revoked/unknown');verify(actor.publicKey,proof); if(op==='USE'){check(relation.entitled,'not currently entitled');state='accepted';} else if(op==='REVOKE'){ check(relation.keys.has(target),'unknown revocation target');relation.keys.get(target).state='revoked';relation.revision++;state='revoked'; }else{ relation.state='retracted';for(const k of relation.keys.values())k.state='revoked';relation.revision++;state='retracted'; } } // Production must make ALL the checks + state change + consumption + receipt durable and atomic. const receipt=signed(this.key,['RESULT',VERSION,SUITE,op,tx,provider,rid,cid,target,phash,'OK',state,String(relation.revision),'online-store']); this.completed.set(tx,{hash:phash,context:trustedContext,receipt});this.pending.delete(tx); return {receipt,newlyCommitted:true}; // Gate physical actuation on this flag; integrate a durable outbox. } revokeViaRecovery(rid,target,authority){ check(authority.operations.includes('REVOKE')&&authority.context,'recovery authority'); const r=this.relations.get(rid);check(r&&r.account===authority.account&&r.state==='active','account'); check(r.keys.has(target),'target');r.keys.get(target).state='revoked';r.revision++; return signed(this.key,['RESULT',VERSION,SUITE,'REVOKE',randomBytes(16).toString('hex'),this.provider,rid,'',target,'','OK','revoked',String(r.revision),'online-store']); } } export class Token { constructor(provider,pin,rid=randomBytes(16).toString('hex'),key=freshKey()){ Object.assign(this,{provider,pin,rid,key});this.cid=randomBytes(16).toString('hex');this.state='pending';this.revision=0;this.awaiting=new Map(); } respond(challenge,approved){ const c=header(challenge,'CHALLENGE',11);verify(this.pin,challenge); check(c[5]===this.provider&&(c[6]===this.rid||(c[3]==='USE'&&c[6]==='')),'provider/relationship'); check(hex(c[8],32)&&/^\d+$/.test(c[9])&&c[10],'challenge fields'); check(approved&&!['revoked','retracted','forgotten'].includes(this.state),'local consent/state'); check(targetOK(c[3],c[7]),'target');check(c[3]==='REGISTER'||this.state==='active','not enrolled'); const p=signed(this.key,['PROOF',VERSION,SUITE,c[3],c[4],this.provider,this.rid,this.cid,c[7],digest(challenge),c[3]==='REGISTER'?rawPublic(this.key):'']); this.awaiting.set(c[4],p);return p; } accept(receipt){ const f=header(receipt,'RESULT',14);verify(this.pin,receipt);const p=this.awaiting.get(f[4]); check(p&&equal(f.slice(3,9),p.fields.slice(3,9))&&f[9]===digest(p)&&f[10]==='OK','receipt binding'); check(f[13]==='online-store'&&/^\d+$/.test(f[12])&&Number(f[12])>=this.revision,'receipt scope/revision'); check(f[11]==={REGISTER:'registered',USE:'accepted',REVOKE:'revoked',RETRACT:'retracted'}[f[3]],'receipt state'); this.revision=Number(f[12]); if(f[3]==='REGISTER')this.state='active'; else if(f[3]==='RETRACT')this.state='retracted'; else if(f[3]==='REVOKE'&&f[8]===this.cid)this.state='revoked'; } forgetLocally(){this.state='forgotten';this.awaiting.clear();} // No remote revocation or secure erasure is implied. } export function presentLegacy(profile,ownerUnlocked,selected){ check(ownerUnlocked&&selected&&profile.selection==='explicit','local selection/unlock'); check(['barcode','qr','nfc-uid','nfc-application'].includes(profile.medium),'medium'); check(profile.authority==='provider-approved'&&profile.value,'approval/value'); return profile.value; // Renderer/driver receives ONLY the agreed value. NFC UID is not writable via HCE. }