86 lines
7.4 KiB
TypeScript
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)
|
|
}))
|