Files
MikrotikManager/backend/scripts/writer-profile/source-activation.mts
T

151 lines
9.5 KiB
TypeScript

/** Deployment operator state. Store outside PGDATA, backups and runtime mounts. */
import assert from 'node:assert/strict'
import {constants} from 'node:fs'
import {lstat,open,rename} from 'node:fs/promises'
import path from 'node:path'
import {z} from 'zod'
import {sourceBindingBytes,sourceBindingCanonical,type DatabaseSourceBindingBody} from '../../src/db/database-source-binding.js'
import {protectedDirectory,readProtected,sha,withJournalLease} from './operator-files.mjs'
import {readProtectedDatabaseSourceBindingRegistry, type createProtectedDatabaseSourceBindingVerifier} from './protected-source-registry.mjs'
const digest=z.string().regex(/^[a-f0-9]{64}$/),uuid=z.string().uuid()
const sourcePin=z.object({registryDirectory:z.string().min(1).max(4096),installationId:uuid,publicKeySpkiSha256:digest,currentHeadSha256:digest}).strict()
const directoryPath=z.string().min(1).max(4096)
const approval=z.object({format:z.literal('database-source-activation-approval-v1'),activationDirectory:directoryPath,sourcePin,tlsSpkiSha256:digest,
expectedActivationSha256:digest.nullable()}).strict()
const generation=z.object({generation:uuid,headSha256:digest,bindingSha256:digest,tlsSpkiSha256:digest}).strict()
const stateSchema=z.object({format:z.literal('database-source-activation-v1'),activationDirectory:directoryPath,installationId:uuid,sourceIdentityId:uuid,
publicKeySpkiSha256:digest,status:z.enum(['active','revoked']),generations:z.array(generation).min(1).max(256)}).strict()
const maximum=131072, filename='source-activation.json'
type State=z.infer<typeof stateSchema>
const parse=(bytes:Buffer):unknown=>JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(bytes))
async function present(file:string){
try{await lstat(file);return true}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')return false;throw error}
}
// Caller holds the same protected lease used by all activation writers.
async function current(directory:string){
const file=path.join(directory,filename)
assert(!await present(file+'.pending'),'Unresolved activation transition')
if(!await present(file))return undefined
const bytes=await readProtected(file,maximum),state=stateSchema.parse(parse(bytes))
assert.equal(state.activationDirectory,directory,'Activation record belongs to another directory')
assert(sourceBindingBytes(state).equals(bytes),'Noncanonical activation record')
for(const field of ['generation','headSha256','tlsSpkiSha256'] as const){
assert.equal(new Set(state.generations.map(item=>item[field].toLowerCase())).size,state.generations.length,'Reused activation identity')
}
return {state,activationSha256:sha(bytes)}
}
async function persist(directory:string,state:State){
await protectedDirectory(directory)
const bytes=sourceBindingBytes(stateSchema.parse(state))
assert(bytes.length<=maximum)
const file=path.join(directory,filename),pending=file+'.pending'
const parent=await open(directory,constants.O_RDONLY|constants.O_DIRECTORY|constants.O_NOFOLLOW)
let confirmed=false,cleanupUnconfirmed=false
try{
const handle=await open(pending,constants.O_WRONLY|constants.O_CREAT|constants.O_EXCL|constants.O_NOFOLLOW,0o600)
try{await handle.writeFile(bytes);await handle.sync()}finally{await handle.close()}
try{await rename(pending,file);await parent.sync()}
catch{throw Object.assign(Error('Activation terminal requires inspection'),{code:'SOURCE_ACTIVATION_TERMINAL_UNKNOWN'})}
confirmed=true
}finally{try{await parent.close()}catch{cleanupUnconfirmed=confirmed}}
return {activationSha256:sha(bytes),cleanupUnconfirmed}
}
function receipt(state:State,activationSha256:string,outcome:'committed'|'observed'){
return {event:'database-source-activation',outcome,activationSha256,status:state.status,
ordinal:state.generations.length,...state.generations.at(-1),generationAuthenticated:false,readyAuthorized:false,automaticReplay:false}
}
type SourceVerifier=ReturnType<typeof createProtectedDatabaseSourceBindingVerifier>
/** Keep revocation and generation changes fenced throughout one read-only SQL preflight. */
export async function withActiveSourcePreflight<T>(input:{directory:string,expectedSha256:string,sourcePin:z.infer<typeof sourcePin>,expectedSource:DatabaseSourceBindingBody['source'],tlsSpkiSha256:string,deadlineAt:number},
verifier:SourceVerifier,work:(guardedVerifier:SourceVerifier)=>Promise<T>){
const {directory,deadlineAt}=input
const expectedSha256=digest.parse(input.expectedSha256),tlsSpkiSha256=digest.parse(input.tlsSpkiSha256)
const pin=sourcePin.parse(input.sourcePin)
return withJournalLease(directory,async()=>{
async function readActive(){
assert(Date.now()<deadlineAt,'Activation preflight expired')
const active=await current(directory)
assert(active?.state.status==='active'&&active.activationSha256===expectedSha256,'Active activation digest changed')
assert(Date.now()<deadlineAt,'Activation preflight expired')
return active.state
}
const state=await readActive(),generation=state.generations.at(-1)!
const registry=await readProtectedDatabaseSourceBindingRegistry(pin,{deadlineAt})
assert(state.publicKeySpkiSha256===registry.publicKeySpkiSha256&&generation.tlsSpkiSha256===tlsSpkiSha256,'Activation key differs from preflight')
function assertGeneration(evidence:{installationId:string,sourceIdentityId:string,generation:string,ordinal:number,headSha256:string,bindingSha256:string}){
assert(evidence.installationId===state.installationId&&evidence.sourceIdentityId===state.sourceIdentityId&&
evidence.generation===generation.generation&&evidence.ordinal===state.generations.length&&
evidence.headSha256===generation.headSha256&&evidence.bindingSha256===generation.bindingSha256,'Activation differs from signed source generation')
}
assertGeneration({...registry.head,headSha256:registry.headSha256})
assert.equal(sourceBindingCanonical(registry.body.source),sourceBindingCanonical(input.expectedSource),'Activation source differs from reviewed configuration')
await readActive()
let sourceChecks=0
const guardedVerifier:SourceVerifier=Object.freeze({async verify(...args:Parameters<SourceVerifier['verify']>){
await readActive()
const evidence=await verifier.verify(...args)
assert(evidence.platformProtectionVerified&&evidence.operatorSignatureVerified&&evidence.currentHeadPinVerified&&evidence.genuineSourceFactsMatched,'Unprotected activation source evidence')
assertGeneration(evidence)
await readActive()
sourceChecks++
return evidence
}})
const result=await work(guardedVerifier)
assert.equal(sourceChecks,2,'Initial and terminal source evidence required')
await readActive()
return result
})
}
export async function sourceActivationMain(args:string[]){
const command=args[0], flags=command==='source-activate'?['directory','approval','approval-sha256']:
command==='source-revoke'?['directory','expected-sha256']:['directory']
assert(['source-activate','source-revoke','source-status'].includes(command)&&args.length===1+2*flags.length)
const values=new Map<string,string>()
for(let i=1;i<args.length;i+=2){
const name=args[i].slice(2)
assert(args[i].startsWith('--')&&flags.includes(name)&&!values.has(name)&&args[i+1]);values.set(name,args[i+1])
}
const directory=values.get('directory')!
return withJournalLease(directory,async()=>{
const before=await current(directory)
if(command==='source-status'){
assert(before,'Activation record absent')
return receipt(before.state,before.activationSha256,'observed')
}
if(command==='source-revoke'){
const expected=digest.parse(values.get('expected-sha256'))
assert(before?.activationSha256===expected&&before.state.status==='active','Active activation digest changed')
const revoked={...before.state,status:'revoked' as const}
const saved=await persist(directory,revoked)
return {...receipt(revoked,saved.activationSha256,'committed'),cleanupUnconfirmed:saved.cleanupUnconfirmed}
}
const request=approval.parse(parse(await readProtected(values.get('approval')!,16384,digest.parse(values.get('approval-sha256')))))
assert.equal(request.activationDirectory,directory,'Approval belongs to another activation directory')
assert.equal(before?.activationSha256??null,request.expectedActivationSha256,'Activation predecessor changed')
const registry=await readProtectedDatabaseSourceBindingRegistry(request.sourcePin,{deadlineAt:Date.now()+120000})
const previous=before?.state.generations??[]
assert.equal(registry.head.ordinal,previous.length+1,'Source ordinal changed')
assert.equal(registry.head.previousHeadSha256,previous.at(-1)?.headSha256??null,'Signed predecessor differs')
if(before){
assert.equal(registry.head.installationId,before.state.installationId)
assert.equal(registry.head.sourceIdentityId,before.state.sourceIdentityId)
assert.equal(registry.publicKeySpkiSha256,before.state.publicKeySpkiSha256)
}
assert(!previous.some(item=>item.generation.toLowerCase()===registry.head.generation.toLowerCase()||item.tlsSpkiSha256===request.tlsSpkiSha256),'Generation or TLS key was already used')
const next:State={format:'database-source-activation-v1',activationDirectory:directory,installationId:registry.head.installationId,
sourceIdentityId:registry.head.sourceIdentityId,publicKeySpkiSha256:registry.publicKeySpkiSha256,status:'active',
generations:[...previous,{generation:registry.head.generation,headSha256:registry.headSha256,
bindingSha256:registry.head.bindingSha256,tlsSpkiSha256:request.tlsSpkiSha256}]}
const saved=await persist(directory,next)
return {...receipt(next,saved.activationSha256,'committed'),cleanupUnconfirmed:saved.cleanupUnconfirmed}
})
}