47 lines
9.7 KiB
JavaScript
47 lines
9.7 KiB
JavaScript
import {randomUUID,createHash,createPrivateKey,createPublicKey,sign,verify as verifySignature} from 'node:crypto';
|
|||
|
|
import {readFileSync,lstatSync} from 'node:fs';
|
||
|
|
import {isAbsolute,dirname} from 'node:path';
|
||
|
|
import {assertPrivateEvidenceKey} from './evidence-key.mjs';
|
||
|
|
const hash=value=>createHash('sha256').update(value).digest('hex');
|
||
|
|
const eventDigest=row=>hash(JSON.stringify(['SafetyNet.audit.event.v1',Number(row.id),row.time,row.device_id??null,row.kind,row.metadata]));
|
||
|
|
const zero='0'.repeat(64);
|
||
|
|
function loadKey(file){
|
||
|
|
if(!isAbsolute(file))throw Error('Audit signing key requires an absolute protected path');
|
||
|
|
for(let path=file;;path=dirname(path)){if(lstatSync(path).isSymbolicLink())throw Error('Audit signing key path must not contain links');if(dirname(path)===path)break;}
|
||
|
|
const stat=lstatSync(file);if(!stat.isFile()||stat.size>65536||stat.size<1)throw Error('Invalid audit signing key file');
|
||
|
|
if(process.platform!=='win32'&&((stat.mode&0o077)!==0||(typeof process.getuid==='function'&&stat.uid!==process.getuid())))throw Error('Audit signing key requires private owner permissions');
|
||
|
|
assertPrivateEvidenceKey(file);const bytes=readFileSync(file);try{const key=createPrivateKey(bytes);if(key.asymmetricKeyType!=='ml-dsa-87')throw Error('Audit signing key must be ML-DSA-87');return key;}finally{bytes.fill(0);}
|
||
|
|
}
|
||
|
|
// Digests survive authorized retention; plaintext remains exclusively in events.
|
||
|
|
export function initializeAuditIntegrity(db,{signingKeyFile}={}){
|
||
|
|
const key=signingKeyFile?loadKey(signingKeyFile):null;
|
||
|
|
const publicKey=key?createPublicKey(key).export({type:'spki',format:'pem'}).toString():null;
|
||
|
|
const existed=!!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_integrity'").get();
|
||
|
|
db.exec(`CREATE TABLE IF NOT EXISTS audit_integrity(seq INTEGER PRIMARY KEY AUTOINCREMENT,type TEXT NOT NULL,event_id INTEGER,body TEXT NOT NULL,previous_hash TEXT NOT NULL,record_hash TEXT NOT NULL,signature TEXT,public_key TEXT); CREATE UNIQUE INDEX IF NOT EXISTS audit_event_unique ON audit_integrity(event_id) WHERE type='event';`);
|
||
|
|
db.exec('CREATE TABLE IF NOT EXISTS audit_integrity_config(id INTEGER PRIMARY KEY CHECK(id=1),public_key TEXT,installation_id TEXT NOT NULL)');
|
||
|
|
const configured=db.prepare('SELECT public_key,installation_id FROM audit_integrity_config WHERE id=1').get();
|
||
|
|
if(configured?.public_key&&configured.public_key!==publicKey)throw Error('Audit signing key unavailable or changed; explicit key rotation required');
|
||
|
|
const installationId=configured?.installation_id||randomUUID();
|
||
|
|
if(!configured)db.prepare('INSERT INTO audit_integrity_config(id,public_key,installation_id) VALUES(1,?,?)').run(publicKey,installationId);
|
||
|
|
else if(publicKey&&!configured.public_key){if(db.prepare('SELECT count(*) AS count FROM audit_integrity').get().count)throw Error('Cannot enable signing on an unsigned audit chain');db.prepare('UPDATE audit_integrity_config SET public_key=? WHERE id=1').run(publicKey);}
|
||
|
|
function head(){const row=db.prepare('SELECT seq,record_hash FROM audit_integrity ORDER BY seq DESC LIMIT 1').get();return {sequence:row?.seq||0,hash:row?.record_hash||zero};}
|
||
|
|
function add(type,eventId,body,unsigned=false){const current=head(),previous=current.hash,serialized=JSON.stringify(body),digest=hash(JSON.stringify(['SafetyNet.audit.chain.v1',installationId,current.sequence+1,type,eventId,serialized,previous]));const signature=key&&!unsigned?sign(null,Buffer.from(digest,'hex'),key).toString('base64'):null;db.prepare('INSERT INTO audit_integrity(type,event_id,body,previous_hash,record_hash,signature,public_key) VALUES(?,?,?,?,?,?,?)').run(type,eventId,serialized,previous,digest,signature,signature?publicKey:null);return head();}
|
||
|
|
function append(row){if(!Number.isSafeInteger(Number(row.id))||typeof row.metadata!=='string')throw Error('Invalid audit event');return add('event',Number(row.id),{digest:eventDigest(row),legacy:false});}
|
||
|
|
// A migration establishes a baseline only; it cannot attest to past integrity.
|
||
|
|
if(!existed){db.exec('SAVEPOINT audit_baseline');try{for(const row of db.prepare("SELECT * FROM events WHERE id NOT IN (SELECT event_id FROM audit_integrity WHERE type='event') ORDER BY id").all())add('event',Number(row.id),{digest:eventDigest(row),legacy:true},true);db.exec('RELEASE audit_baseline');}catch(error){db.exec('ROLLBACK TO audit_baseline');db.exec('RELEASE audit_baseline');throw error;}}
|
||
|
|
if(key&&(!existed||configured&&!configured.public_key&&head().sequence===0))add('baseline',null,{legacySequence:head().sequence,legacyHead:head().hash});
|
||
|
|
function authorizePrune(ids,retentionEventId=null){if(!Array.isArray(ids)||ids.length>1000||new Set(ids).size!==ids.length)throw Error('Invalid audit retention selection');const retention=db.prepare('SELECT kind FROM events WHERE id=?').get(retentionEventId);if(!retention||retention.kind!=='audit.retention-applied')throw Error('Audit retention requires its audit event');
|
||
|
|
const entries=ids.map(id=>{if(!Number.isSafeInteger(id)||id<1)throw Error('Invalid audit retention event');const row=db.prepare('SELECT * FROM events WHERE id=?').get(id),proof=db.prepare("SELECT body FROM audit_integrity WHERE type='event' AND event_id=?").get(id);if(row?.kind==='audit.retention-applied')throw Error('Retention authorization audit events must be preserved');if(!row||!proof||eventDigest(row)!==JSON.parse(proof.body).digest)throw Error('Audit retention rejected a changed event');return {id,digest:eventDigest(row)};});return add('retention',null,{entries,retentionEventId});}
|
||
|
|
function proof(eventId){const entry=db.prepare("SELECT type,event_id AS eventId,seq AS sequence,record_hash AS hash,previous_hash AS previousHash,signature,public_key AS publicKey,body FROM audit_integrity WHERE type='event' AND event_id=?").get(eventId);return entry?{...entry,installationId,algorithm:entry.signature?'ML-DSA-87':null}:null;}
|
||
|
|
function check({trustedPublicKey,expectedHead}={}){
|
||
|
|
const errors=[],rows=db.prepare('SELECT * FROM audit_integrity ORDER BY seq').all(),retained=new Map(),eventEntries=new Map(rows.filter(row=>row.type==='event').map(row=>[row.event_id,row]));let previous=zero,sequence=0,legacy=0,signed=0,sealed=false;const pin=trustedPublicKey||publicKey;
|
||
|
|
for(const row of rows){try{if(row.seq!==sequence+1)errors.push('sequence-gap');if(row.previous_hash!==previous)errors.push('chain-link');const digest=hash(JSON.stringify(['SafetyNet.audit.chain.v1',installationId,row.seq,row.type,row.event_id,row.body,row.previous_hash]));if(digest!==row.record_hash)errors.push('record-digest');if(row.signature){signed++;if(!row.public_key||!verifySignature(null,Buffer.from(row.record_hash,'hex'),createPublicKey(row.public_key),Buffer.from(row.signature,'base64')))errors.push('signature');if(pin&&createPublicKey(row.public_key).export({type:'spki',format:'der'}).toString('hex')!==createPublicKey(pin).export({type:'spki',format:'der'}).toString('hex'))errors.push('signing-key');}const body=JSON.parse(row.body);if(row.type==='event'){if(body.legacy){legacy++;if(pin&&sealed)errors.push('legacy-after-seal');}else if(pin&&!row.signature)errors.push('unsigned-event');}else if(row.type==='retention'){if(pin&&!row.signature)errors.push('unsigned-retention');const authorization=eventEntries.get(body.retentionEventId),event=db.prepare('SELECT kind FROM events WHERE id=?').get(body.retentionEventId);if(!authorization||authorization.seq>=row.seq||!event||event.kind!=='audit.retention-applied')errors.push('retention-authorization');for(const item of body.entries){const original=eventEntries.get(item.id);if(!original||original.seq>=row.seq||JSON.parse(original.body).digest!==item.digest||retained.has(item.id)||item.id===body.retentionEventId)errors.push('retention-entry');retained.set(item.id,item.digest);}}else if(row.type==='baseline'){if(sealed||!row.signature||body.legacySequence!==row.seq-1||body.legacyHead!==row.previous_hash)errors.push('baseline-seal');sealed=true;}else errors.push('record-type');}catch{errors.push('malformed-record');}previous=row.record_hash;sequence=row.seq;}
|
||
|
|
for(const row of rows.filter(row=>row.type==='event')){try{const body=JSON.parse(row.body),event=db.prepare('SELECT * FROM events WHERE id=?').get(row.event_id);if(event){if(eventDigest(event)!==body.digest)errors.push('event-digest');}else if(retained.get(row.event_id)!==body.digest)errors.push('missing-event');}catch{errors.push('malformed-event');}}
|
||
|
|
if(pin&&!sealed)errors.push('missing-baseline-seal');
|
||
|
|
const uncovered=db.prepare("SELECT count(*) AS count FROM events WHERE id NOT IN (SELECT event_id FROM audit_integrity WHERE type='event')").get().count;if(uncovered)errors.push('uncovered-events');
|
||
|
|
if(expectedHead&&(expectedHead.hash!==previous||expectedHead.sequence!==sequence))errors.push('anchor-mismatch');return {valid:errors.length===0,errors:[...new Set(errors)],head:{sequence,hash:previous},records:rows.length,legacyEvents:legacy,signedRecords:signed,authenticated:!!pin&&signed>0&&errors.length===0,externallyAnchored:!!trustedPublicKey&&!!expectedHead&&errors.length===0,limitations:['Legacy baseline does not attest to historical integrity.','An independently retained public key and head are required to detect database replacement or tail truncation.']};
|
||
|
|
}
|
||
|
|
function entries({after=0,limit=100}={}){if(!Number.isSafeInteger(after)||after<0||!Number.isSafeInteger(limit)||limit<1||limit>1000)throw Error('Invalid audit proof pagination');return {entries:db.prepare('SELECT type,event_id AS eventId,seq AS sequence,record_hash AS hash,previous_hash AS previousHash,signature,public_key AS publicKey,body FROM audit_integrity WHERE seq>? ORDER BY seq LIMIT ?').all(after,limit),head:head(),publicKey,installationId,algorithm:key?'ML-DSA-87':null};}
|
||
|
|
if(existed&&!check().valid)throw Error('Audit integrity verification failed at startup');
|
||
|
|
return {append,authorizePrune,verify:check,head,proof,linkage:proof,entries};
|
||
|
|
}
|