135 lines
9.8 KiB
TypeScript
135 lines
9.8 KiB
TypeScript
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 {createHash,generateKeyPairSync,randomUUID} from 'node:crypto'
|
|
import {createRequire} from 'node:module'
|
|
import {runInNewContext} from 'node:vm'
|
|
import {provisionDatabaseSourceBindingOffline} from '../database-source-binding-operator.ts'
|
|
import {readDatabaseSourceBindingRegistry,sourceBindingBytes,sourceBindingCanonical,type DatabaseSourceDeploymentPin} from '../../src/db/database-source-binding.ts'
|
|
import type * as Activation from './source-activation.mts'
|
|
|
|
const require=createRequire(import.meta.url),ts=require('typescript')
|
|
const source=await fs.readFile(new URL('./source-activation.mts',import.meta.url),'utf8')
|
|
const sha=(value:Buffer)=>createHash('sha256').update(value).digest('hex')
|
|
|
|
test('protected activation transition model preserves revocation and refuses concurrent, stale or reused generations',async()=>{
|
|
const root=await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(),'mm-source-activation-test-')))
|
|
try{
|
|
// Genuine issuer signatures and filesystem files. Windows models the
|
|
// protection/lease boundary and directory fsync used on the POSIX target.
|
|
const lock=path.join(root,'test-lease'),state=path.join(root,'source-activation.json')
|
|
const files={sha,protectedDirectory:async(directory:string)=>{assert.equal(directory,root)},
|
|
readProtected:async(filename:string,maximum:number,expected?:string)=>{
|
|
assert.equal(path.dirname(filename),root)
|
|
const bytes=await fs.readFile(filename);assert(bytes.length<=maximum)
|
|
if(expected)assert.equal(sha(bytes),expected)
|
|
return bytes
|
|
},async withJournalLease(directory:string,work:()=>Promise<unknown>){
|
|
assert.equal(directory,root);const handle=await fs.open(lock,'wx')
|
|
try{return await work()}finally{await handle.close();await fs.unlink(lock)}
|
|
}}
|
|
let parentFailure=''
|
|
const modelFs={...fs,open:async(filename:string,flags:number,mode?:number)=>filename===root?
|
|
{sync:async()=>{if(parentFailure==='sync')throw Error('fixture directory sync failure')},
|
|
close:async()=>{if(parentFailure==='close')throw Error('fixture directory close failure')}}:fs.open(filename,flags,mode)}
|
|
const imports:Record<string,unknown>={'node:fs/promises':modelFs,'./operator-files.mjs':files,
|
|
'./protected-source-registry.mjs':{readProtectedDatabaseSourceBindingRegistry:readDatabaseSourceBindingRegistry},
|
|
'../../src/db/database-source-binding.js':{sourceBindingBytes,sourceBindingCanonical}}
|
|
const load=()=>{
|
|
const exports={}
|
|
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText
|
|
runInNewContext(compiled,{exports,require:(name:string)=>imports[name]??require(name),Buffer,TextDecoder})
|
|
return exports as typeof Activation
|
|
}
|
|
const {withActiveSourcePreflight}=load()
|
|
let {sourceActivationMain:run}=load()
|
|
const privateKeyFile=path.join(root,'issuer.pem'),key=generateKeyPairSync('ed25519').privateKey
|
|
await fs.writeFile(privateKeyFile,key.export({format:'pem',type:'pkcs8'}),{mode:0o600})
|
|
const installationId=randomUUID(),sourceIdentityId=randomUUID()
|
|
const issue=async(previousPin?:DatabaseSourceDeploymentPin,generation=randomUUID())=>{
|
|
return (await provisionDatabaseSourceBindingOffline({privateKeyFile,previousPin,outputDirectory:path.join(root,randomUUID()),
|
|
request:{installationId,sourceIdentityId,generation,source:{systemIdentifier:'123',databaseOid:16384,databaseName:'synthetic',
|
|
sessionUser:'publisher',currentUser:'publisher',inRecovery:false,serverVersionNum:180004}}})).deploymentPin
|
|
}
|
|
const status=['source-status','--directory',root]
|
|
const revoke=(expected:string)=>['source-revoke','--directory',root,'--expected-sha256',expected]
|
|
const activate=async(pin:DatabaseSourceDeploymentPin,tlsSpkiSha256:string,expectedActivationSha256:string|null,activationDirectory=root)=>{
|
|
const bytes=sourceBindingBytes({format:'database-source-activation-approval-v1',activationDirectory,sourcePin:pin,tlsSpkiSha256,expectedActivationSha256})
|
|
const file=path.join(root,randomUUID()+'.json');await fs.writeFile(file,bytes,{mode:0o600,flag:'wx'})
|
|
return ['source-activate','--directory',root,'--approval',file,'--approval-sha256',sha(bytes)]
|
|
}
|
|
const g1=await issue(undefined,'5ac5e8bd-0eb2-498b-901a-dd2010cd4b31'),g2=await issue(g1),g3=await issue(g2)
|
|
await assert.rejects(run(await activate(g1,'1'.repeat(64),null,path.join(root,'other'))),/another activation directory/)
|
|
const start=await activate(g1,'1'.repeat(64),null)
|
|
const races=await Promise.allSettled([run(start),run(start)])
|
|
assert.deepEqual(races.map(value=>value.status).sort(),['fulfilled','rejected'],races.map(value=>value.status==='rejected'?String(value.reason):value.status).join(', '))
|
|
const first=await run(status)
|
|
assert.equal(first.ordinal,1);assert.equal(first.headSha256,g1.currentHeadSha256)
|
|
assert.equal(first.generationAuthenticated,false)
|
|
const registry=await readDatabaseSourceBindingRegistry(g1)
|
|
const evidence={format:'database-source-binding-evidence-v1' as const,
|
|
installationId:registry.head.installationId,sourceIdentityId:registry.head.sourceIdentityId,generation:registry.head.generation,
|
|
ordinal:registry.head.ordinal,bindingSha256:registry.head.bindingSha256,headSha256:registry.headSha256,
|
|
operatorSignatureVerified:true as const,currentHeadPinVerified:true as const,
|
|
genuineSourceFactsMatched:true as const,platformProtectionVerified:true as const,generationAuthenticated:false as const,
|
|
allWriterDrainAuthenticated:false as const,readyAuthorized:false as const}
|
|
// The actual preflight exercises the checked-out PostgreSQL client. The guard fixture models its source verifier.
|
|
type Verifier=Parameters<typeof withActiveSourcePreflight>[1]
|
|
const verifierArgs=[] as unknown as Parameters<Verifier['verify']>
|
|
const preflight={directory:root,sourcePin:g1,expectedSource:registry.body.source,expectedSha256:first.activationSha256,tlsSpkiSha256:'1'.repeat(64),deadlineAt:Date.now()+30000}
|
|
const refuseConnect=()=>{throw Error('Reached SQL connection before activation was trusted')}
|
|
const verifier={verify:async()=>evidence}
|
|
await assert.rejects(withActiveSourcePreflight({...preflight,expectedSha256:'0'.repeat(64)},verifier,refuseConnect),/activation digest changed/)
|
|
await assert.rejects(withActiveSourcePreflight({...preflight,tlsSpkiSha256:'2'.repeat(64)},verifier,refuseConnect),/Activation key differs/)
|
|
await assert.rejects(withActiveSourcePreflight({...preflight,sourcePin:g2},verifier,refuseConnect),/differs from signed source generation/)
|
|
await assert.rejects(withActiveSourcePreflight({...preflight,expectedSource:{...preflight.expectedSource,currentUser:'different'}},verifier,refuseConnect),/differs from reviewed configuration/)
|
|
let verifications=0
|
|
const result=await withActiveSourcePreflight(preflight,{verify:async()=>{verifications++;return evidence}},async guarded=>{
|
|
assert.equal(await guarded.verify(...verifierArgs),evidence)
|
|
await assert.rejects(run(revoke(first.activationSha256)),{code:'EEXIST'})
|
|
assert.equal(await guarded.verify(...verifierArgs),evidence)
|
|
return 'matched'
|
|
})
|
|
assert.equal(result,'matched');assert.equal(verifications,2)
|
|
await assert.rejects(fs.access(lock),{code:'ENOENT'})
|
|
await assert.rejects(withActiveSourcePreflight(preflight,verifier,async guarded=>{
|
|
await guarded.verify(...verifierArgs)
|
|
}),/Initial and terminal source evidence required/)
|
|
let reads=0
|
|
await assert.rejects(withActiveSourcePreflight(preflight,{verify:async()=>++reads===1?evidence:{...evidence,bindingSha256:'0'.repeat(64)}},async guarded=>{
|
|
await guarded.verify(...verifierArgs)
|
|
await guarded.verify(...verifierArgs)
|
|
}),/differs from signed source generation/)
|
|
await fs.writeFile(state+'.pending','interrupted',{flag:'wx'})
|
|
await assert.rejects(withActiveSourcePreflight(preflight,verifier,refuseConnect),/Unresolved activation transition/)
|
|
await fs.unlink(state+'.pending')
|
|
await assert.rejects(run(start),/predecessor changed/)
|
|
const revoked=await run(revoke(first.activationSha256))
|
|
await assert.rejects(withActiveSourcePreflight({...preflight,expectedSha256:revoked.activationSha256},verifier,refuseConnect),/activation digest changed/)
|
|
run=load().sourceActivationMain
|
|
assert.equal((await run(status)).status,'revoked')
|
|
await assert.rejects(run(revoke(first.activationSha256)),/digest changed/)
|
|
await assert.rejects(run(await activate(g3,'3'.repeat(64),revoked.activationSha256)),/ordinal changed/)
|
|
const second=await run(await activate(g2,'2'.repeat(64),revoked.activationSha256))
|
|
assert.equal(second.ordinal,2);assert.equal(second.status,'active')
|
|
await assert.rejects(run(await activate(g3,'1'.repeat(64),second.activationSha256)),/already used/)
|
|
const repeatedGeneration=await issue(g2,first.generation!.toUpperCase())
|
|
await assert.rejects(run(await activate(repeatedGeneration,'3'.repeat(64),second.activationSha256)),/already used/)
|
|
await assert.rejects(run(await activate({...g3,currentHeadSha256:'0'.repeat(64)},'3'.repeat(64),second.activationSha256)))
|
|
assert.equal((await run(status)).activationSha256,second.activationSha256)
|
|
parentFailure='sync'
|
|
await assert.rejects(run(revoke(second.activationSha256)),{code:'SOURCE_ACTIVATION_TERMINAL_UNKNOWN'})
|
|
parentFailure='close'
|
|
const observed=await run(status)
|
|
assert.equal(observed.status,'revoked')
|
|
const third=await run(await activate(g3,'3'.repeat(64),observed.activationSha256))
|
|
assert.equal(third.outcome,'committed')
|
|
assert('cleanupUnconfirmed' in third && third.cleanupUnconfirmed===true)
|
|
await fs.writeFile(state+'.pending','interrupted',{flag:'wx'})
|
|
await assert.rejects(run(status),/Unresolved activation transition/)
|
|
await assert.rejects(run(revoke(third.activationSha256)),/Unresolved activation transition/)
|
|
}finally{await fs.rm(root,{recursive:true,force:true})}
|
|
})
|