Files
MikrotikManager/backend/scripts/writer-profile/protected-source-registry.test.mts
T

86 lines
7.4 KiB
TypeScript

/** Genuine Ed25519/files; POSIX permission metadata is modeled on Windows.
* No SQL, service, deployment or real POSIX acceptance is claimed. */
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import crypto from 'node:crypto'
import {createRequire} from 'node:module'
import type {Stats,BigIntStats} from 'node:fs'
import type {DatabaseSourceDeploymentPin} from '../../src/db/database-source-binding.ts'
import type * as ProtectedApi from './protected-source-registry.mts'
import {runInNewContext} from 'node:vm'
import {readProtectedDatabaseSourceBindingRegistry} from './protected-source-registry.mts'
import {readDatabaseSourceBindingRegistry,sourceBindingBytes,sourceBindingCanonical,sourceBindingSha256,DATABASE_SOURCE_SIGNATURE_DOMAINS} from '../../src/db/database-source-binding.ts'
const require=createRequire(import.meta.url),ts=require('typescript')
const helperSource=await fs.readFile(new URL('./operator-files.mjs',import.meta.url),'utf8')
const source=await fs.readFile(new URL('./protected-source-registry.mts',import.meta.url),'utf8')
const deadline=()=>({deadlineAt:Date.now()+10000})
function compile(text:string){return ts.transpileModule(text,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText}
function evaluate<T=Record<string,unknown>>(text:string,imports:Record<string,unknown>,processModel:unknown=process):T{
const exports:Record<string,unknown>={}
runInNewContext(compile(text),{exports,require:(name:string)=>imports[name]??require(name),process:processModel,Buffer,TextDecoder,console})
return exports as T
}
interface Fixture {pin:DatabaseSourceDeploymentPin;faults:Record<string,boolean>;api:typeof ProtectedApi;verificationReads:()=>number;root:string}
async function own(work:(f:Fixture)=>Promise<void>){
const root=await fs.mkdtemp(path.join(os.tmpdir(),'mm-protected-source-'))
try{
const key=crypto.generateKeyPairSync('ed25519'),installationId=crypto.randomUUID()
const signed=(body:unknown,domain:string)=>sourceBindingBytes({body,signature:crypto.sign(null,Buffer.from(domain+sourceBindingCanonical(body)),key.privateKey).toString('base64url')})
const body={format:'database-source-binding-v1',installationId,sourceIdentityId:crypto.randomUUID(),generation:crypto.randomUUID(),ordinal:1,
source:{systemIdentifier:'123',databaseOid:16384,databaseName:'synthetic',sessionUser:'capture',currentUser:'capture',inRecovery:false,serverVersionNum:180004}}
const binding=signed(body,DATABASE_SOURCE_SIGNATURE_DOMAINS.binding),bindingSha256=sourceBindingSha256(binding)
const head=signed({format:'database-source-registry-head-v1',installationId,sourceIdentityId:body.sourceIdentityId,generation:body.generation,ordinal:1,bindingSha256,previousHeadSha256:null},DATABASE_SOURCE_SIGNATURE_DOMAINS.head)
const pin={registryDirectory:root,installationId,publicKeySpkiSha256:sourceBindingSha256(key.publicKey.export({format:'der',type:'spki'})),currentHeadSha256:sourceBindingSha256(head)}
await fs.writeFile(path.join(root,'operator-public.pem'),key.publicKey.export({format:'pem',type:'spki'}),{mode:0o600})
await fs.writeFile(path.join(root,'head-'+pin.currentHeadSha256+'.json'),head,{mode:0o600})
await fs.writeFile(path.join(root,'binding-'+bindingSha256+'.json'),binding,{mode:0o600})
const faults={modeFile:false,modeDirectory:false,symlink:false,replacement:false,lateMode:false,changed:false};let verificationReads=0
const metadata=(s:Stats|BigIntStats,filename:string,big:boolean)=>{
const n=(value:number)=>big?BigInt(value):value,isRoot=filename===root,isFile=s.isFile()
const mode=(isFile?0o100600:0o40700)|((isFile&&faults.modeFile||isRoot&&faults.modeDirectory||isFile&&faults.lateMode&&faults.changed)?0o022:0)
return {...s,mode:n(mode),uid:n(1000),gid:n(1000),ino:faults.replacement&&faults.changed&&isFile?(typeof s.ino==='bigint'?s.ino+1n:s.ino+1):s.ino,
isFile:()=>isFile,isDirectory:()=>s.isDirectory(),isSymbolicLink:()=>faults.symlink&&isRoot}
}
const modelFs={...fs,lstat:async(filename:string,options?:{bigint?:boolean})=>metadata(await fs.lstat(filename,{bigint:options?.bigint===true}),filename,options?.bigint===true),open:async(filename:string,flags:number)=>{
const file=await fs.open(filename,flags)
return {stat:async()=>metadata(await file.stat(),filename,false),read:file.read.bind(file),close:file.close.bind(file)}
}}
const processModel={platform:'linux',getuid:()=>1000}
const proven=evaluate(helperSource,{'node:fs/promises':modelFs},processModel)
const bindingModule={readDatabaseSourceBindingRegistry:async(p:DatabaseSourceDeploymentPin)=>{
verificationReads++;const result=await readDatabaseSourceBindingRegistry(p);faults.changed=true;return result
},createDatabaseSourceBindingVerifier:()=>({verify:async()=>{verificationReads++;faults.changed=true;return {genuineSourceFactsMatched:true,generationAuthenticated:false,readyAuthorized:false}}})}
const api=evaluate<typeof ProtectedApi>(source,{'node:fs/promises':modelFs,'./operator-files.mjs':proven,'../../src/db/database-source-binding.js':bindingModule},processModel)
await work({pin,faults,api,verificationReads:()=>verificationReads,root})
}finally{const rel=path.relative(os.tmpdir(),root);assert(rel&&!rel.startsWith('..')&&!path.isAbsolute(rel));await fs.rm(root,{recursive:true,force:true})}
}
test('unsupported Windows refuses before registry signature reads',{skip:process.platform!=='win32'},async()=>{
await assert.rejects(readProtectedDatabaseSourceBindingRegistry({registryDirectory:path.resolve('not-created-protected-source'),installationId:crypto.randomUUID(),publicKeySpkiSha256:'a'.repeat(64),currentHeadSha256:'b'.repeat(64)},deadline()),/Windows ACL/)
})
test('proven POSIX helpers plus genuine signature/head bytes retain false generation/READY',()=>own(async f=>{
const value=await f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,deadline())
assert.equal(value.platformProtectionVerified,true);assert.equal(value.generationAuthenticated,false);assert.equal(value.allWriterDrainAuthenticated,false);assert.equal(value.readyAuthorized,false)
assert.equal(value.headSha256,f.pin.currentHeadSha256);assert.equal(f.verificationReads(),1)
}))
for(const fault of ['modeFile','modeDirectory','symlink'])test(fault+' refuses before cryptographic registry read',()=>own(async f=>{
f.faults[fault]=true;await assert.rejects(f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,deadline()));assert.equal(f.verificationReads(),0)
}))
for(const fault of ['replacement','lateMode'])test(fault+' during genuine signature verification refuses protected output',()=>own(async f=>{
f.faults[fault]=true;await assert.rejects(f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,deadline()));assert.equal(f.verificationReads(),1)
}))
test('expired/aborted lifetime refuses before filesystem or source access',()=>own(async f=>{
const abort=new AbortController();abort.abort()
for(const options of [{deadlineAt:Date.now()-1},{deadlineAt:Date.now()+1000,signal:abort.signal}])await assert.rejects(f.api.readProtectedDatabaseSourceBindingRegistry(f.pin,options))
assert.equal(f.verificationReads(),0)
}))
test('source verifier drift cannot grant protected output',()=>own(async f=>{
f.faults.lateMode=true
const verify=f.api.createProtectedDatabaseSourceBindingVerifier(f.pin).verify
await assert.rejects(verify({} as Parameters<typeof verify>[0],{} as Parameters<typeof verify>[1],deadline()))
assert.equal(f.verificationReads(),1)
}))