fix(mcp): обновить снимок приёмки маршрутизации и базы данных
This commit is contained in:
1 parent
bf3d50e1ff
commit
6a286a1f66
79 files changed
+10015
-109
No files matched your search
@@ -0,0 +1,129 @@
|
||||
/** Explicit host-operator inspection/acceptance. No restore, service restart, role
|
||||
* enrollment or source/READY issuer. Populated data is accepted, not certified. */
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import {fileURLToPath} from 'node:url'
|
||||
import {mkdir,readdir} from 'node:fs/promises'
|
||||
import {readProtected,protectedDirectory,writeFile,withJournalLease,sha} from './operator-files.mjs'
|
||||
import {poolOptions,STARTUP_OPTIONS,assertJsDriverEnvironment} from './operator-contract.mjs'
|
||||
import {readMaintenanceRecoveryFacts,readMaintenanceAcceptanceRecords} from '../../src/services/database-maintenance-startup.js'
|
||||
import {parseMaintenanceJSON,runtimeDatabaseTargetSha256,evaluateMaintenanceAcceptances,validateMaintenanceKnownCurrent,validateMaintenanceAcceptance,MAINTENANCE_ACCEPTANCE_FORMAT,type MaintenanceKnownCurrent} from '../../src/services/database-maintenance-resolution.js'
|
||||
import {captureMigrationSchema} from '../../src/db/migration-schema-inventory.js'
|
||||
import {MIGRATION_MANIFEST,CURRENT_REQUIRED_TABLES,migrationManifestDigest,inventoryMigrationDirectory} from '../../src/db/migration-manifest.js'
|
||||
import type {Pool,PoolClient} from 'pg'
|
||||
const hex=(v:unknown):v is string=>typeof v==='string'&&/^[a-f0-9]{64}$/.test(v)
|
||||
function object(v:unknown):asserts v is Record<string,unknown>{assert(v&&typeof v==='object'&&!Array.isArray(v))}
|
||||
function exact(v:unknown,keys:string[]):asserts v is Record<string,unknown>{object(v);assert.equal(Object.keys(v).sort().join('\0'),keys.sort().join('\0'))}
|
||||
const stamp=(v:unknown):v is string=>typeof v==='string'&&Number.isFinite(Date.parse(v))&&new Date(v).toISOString()===v
|
||||
const stable=(v:unknown)=>JSON.stringify(v)
|
||||
const flags=['state-dir','transport','transport-sha256','runtime-target','runtime-target-sha256']
|
||||
const validityMs=900000
|
||||
/** Explicit local-container transport never falls back from TLS or environment.
|
||||
* Caller launch review supplies --network container:<exact reviewed PG id>;
|
||||
* this module has no Docker socket/API and claims only loopback + physical SQL proof. */
|
||||
export function maintenancePoolConfiguration(value:unknown,ca?:Buffer,caSha256?:string){
|
||||
object(value);const mode=value.mode===undefined?'tls':value.mode;assert(mode==='tls'||mode==='local-loopback')
|
||||
exact(value,mode==='local-loopback'?['format','mode','connection']:value.mode===undefined?['format','connection','tls']:['format','mode','connection','tls'])
|
||||
assert.equal(value.format,'database-maintenance-transport-v1');exact(value.connection,['host','port','user','password','database']);const connection=value.connection
|
||||
assert(typeof connection.host==='string'&&typeof connection.user==='string'&&/^[^\x00-\x1f\x7f]{1,63}$/.test(connection.user)&&typeof connection.password==='string'&&connection.password.length>0&&connection.password.length<=4096&&!connection.password.includes('\0')&&typeof connection.database==='string'&&/^[^\x00-\x1f\x7f]{1,63}$/.test(connection.database)&&typeof connection.port==='number'&&Number.isSafeInteger(connection.port)&&Number(connection.port)>0&&Number(connection.port)<=65535)
|
||||
let options,identity:unknown
|
||||
if(mode==='tls'){
|
||||
exact(value.tls,['serverName','spkiSha256']);assert(typeof value.tls.serverName==='string'&&/^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(value.tls.serverName)&&hex(value.tls.spkiSha256));assert(ca&&hex(caSha256))
|
||||
options=poolOptions(connection,{roles:{publisher:{name:connection.user}},source:{databaseName:connection.database},tls:{...value.tls,caSha256}},ca)
|
||||
identity={mode,host:connection.host,port:connection.port,database:connection.database,tls:{...value.tls,caSha256}}
|
||||
}else{
|
||||
assert(connection.host==='127.0.0.1'||connection.host==='::1','Plaintext maintenance requires exact loopback and reviewed PostgreSQL container namespace');assert(ca===undefined&&caSha256===undefined)
|
||||
options={host:connection.host,port:connection.port,user:connection.user,password:connection.password,database:connection.database,max:1,connectionTimeoutMillis:5000,idleTimeoutMillis:5000,statement_timeout:10000,lock_timeout:5000,options:STARTUP_OPTIONS,application_name:'mmapp-maintenance-acceptance',client_encoding:'UTF8',ssl:false}
|
||||
identity={mode,host:connection.host,port:connection.port,database:connection.database}
|
||||
}
|
||||
return {options,transportBinding:{mode,endpointSha256:sha(stable(identity))} as MaintenanceKnownCurrent['transport']}
|
||||
}
|
||||
export function maintenanceApproval(value:unknown,inspectionSha256:string,inspectionCreatedAt:string){
|
||||
exact(value,['format','resolutionId','decision','actorId','inspectionSha256','issuedAt','expiresAt','managedBackendStopped','historicOutcomesRemainUnknown','automaticRestore'])
|
||||
assert(value.format==='database-maintenance-current-state-approval-v1'&&value.decision==='accept-observed-current-state'&&typeof value.resolutionId==='string'&&/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value.resolutionId))
|
||||
assert(typeof value.actorId==='string'&&/^[^\x00-\x1f\x7f]{1,160}$/.test(value.actorId)&&value.inspectionSha256===inspectionSha256&&stamp(value.issuedAt)&&stamp(value.expiresAt))
|
||||
const now=Date.now();assert(Date.parse(value.issuedAt)>=Date.parse(inspectionCreatedAt)&&Date.parse(value.issuedAt)<=now&&now<Date.parse(value.expiresAt)&&Date.parse(value.expiresAt)-Date.parse(value.issuedAt)<=validityMs)
|
||||
assert(value.managedBackendStopped===true&&value.historicOutcomesRemainUnknown===true&&value.automaticRestore===false)
|
||||
return value as unknown as {resolutionId:string;actorId:string;issuedAt:string;expiresAt:string}
|
||||
}
|
||||
/** Genuine observation, not a supplied snapshot factory. Owner/transaction ends
|
||||
* must be confirmed before a caller can publish an acceptance. Exclusive key
|
||||
* protects cooperative observations; stopped-backend assumption spans publication. */
|
||||
export async function observeMaintenanceCurrent(pool:Pick<Pool,'connect'>,transport:MaintenanceKnownCurrent['transport']){
|
||||
let client:PoolClient|undefined,locked=false,begun=false,bad=false,faulted=false
|
||||
const fault=()=>{faulted=true},live=()=>{assert(!faulted)}
|
||||
try{
|
||||
client=await pool.connect();client.on('error',fault);client.on('end',fault)
|
||||
const query=async(text:string,values:unknown[]=[])=>{live();const result=await client!.query(text,values);live();return result}
|
||||
await query("SET statement_timeout='10s'");await query("SET lock_timeout='5s'")
|
||||
const lease=await query('SELECT pg_catalog.pg_try_advisory_lock(8723110::bigint) AS locked');assert(lease.rows.length===1&&lease.rows[0].locked===true);locked=true
|
||||
await query('BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY');begun=true
|
||||
for(const sql of ["SET LOCAL search_path=pg_catalog","SET LOCAL TimeZone='UTC'","SET LOCAL DateStyle='ISO, YMD'","SET LOCAL standard_conforming_strings=on","SET LOCAL statement_timeout='10s'"])await query(sql)
|
||||
const identity=await query(`SELECT c.system_identifier::text AS "systemIdentifier",d.oid::text AS "databaseOid",d.datname AS "databaseName",pg_catalog.current_setting('server_version_num')::int AS "serverVersionNum",pg_catalog.pg_is_in_recovery() AS "inRecovery" FROM pg_catalog.pg_database d CROSS JOIN pg_catalog.pg_control_system() c WHERE d.datname=pg_catalog.current_database()`)
|
||||
assert.equal(identity.rows.length,1)
|
||||
// The optional writer-profile control schema is not installed by ordinary
|
||||
// application migrations. Observe its presence in this same transaction;
|
||||
// detailed capture still refuses every other unexpected namespace.
|
||||
const control=await query("SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname='mmapp_control'")
|
||||
assert(control.rows.length<=1&&control.rows.every(row=>row.nspname==='mmapp_control'))
|
||||
const schema=await captureMigrationSchema({query:async(text,values)=>query(text,values)},{scope:{applicationNamespaces:['public'],excludedControlNamespaces:control.rows.length?['mmapp_control']:[]},deadlineAt:Date.now()+60000})
|
||||
const marker=schema.sections.relations.find(row=>row.namespace==='public'&&row.name==='schema_migrations'),column=schema.sections.columns.find(row=>row.namespace==='public'&&row.relation==='schema_migrations'&&row.name==='id'&&row.dropped===false)
|
||||
assert(marker?.kind==='r'&&marker.persistence==='p'&&marker.row_security===false&&marker.force_row_security===false&&column?.type==='text')
|
||||
for(const table of CURRENT_REQUIRED_TABLES)assert(schema.sections.relations.some(row=>row.namespace==='public'&&row.name===table&&['r','p'].includes(String(row.kind))&&row.row_security===false&&row.force_row_security===false))
|
||||
const markers=(await query('SELECT id FROM public.schema_migrations ORDER BY id LIMIT $1',[MIGRATION_MANIFEST.length+1])).rows
|
||||
assert.equal(stable(markers),stable(MIGRATION_MANIFEST.map(entry=>({id:entry.file.slice(0,-4)}))))
|
||||
const knownCurrent={transport,source:identity.rows[0],schemaSha256:schema.sha256,rawSchemaSha256:schema.observedRaw.sha256,physicalSchemaSha256:schema.observedPhysical.sha256,manifestSha256:migrationManifestDigest(),markersSha256:sha(stable(markers))}
|
||||
validateMaintenanceKnownCurrent(knownCurrent)
|
||||
const end=await query('ROLLBACK');assert.equal(end.command,'ROLLBACK');begun=false
|
||||
const unlock=await query('SELECT pg_catalog.pg_advisory_unlock(8723110::bigint) AS unlocked');assert(unlock.rows.length===1&&unlock.rows[0].unlocked===true);locked=false
|
||||
return {knownCurrent,schema}
|
||||
}catch(error){bad=true;throw error}finally{
|
||||
if(client){let cleanupFailed=false
|
||||
try{if(begun&&!faulted){const end=await client.query('ROLLBACK');assert.equal(end.command,'ROLLBACK')}if(locked&&!faulted){const end=await client.query('SELECT pg_catalog.pg_advisory_unlock(8723110::bigint) AS unlocked');assert(end.rows[0]?.unlocked===true)}}catch{cleanupFailed=true}
|
||||
try{client.off('error',fault);client.off('end',fault)}catch{cleanupFailed=true}
|
||||
try{client.release(bad||faulted||cleanupFailed)}catch{cleanupFailed=true}
|
||||
assert(!cleanupFailed&&!faulted,'Read-only maintenance owner cleanup unconfirmed')
|
||||
}
|
||||
}
|
||||
}
|
||||
async function snapshot(stateDirectory:string,target:string,records:readonly Buffer[]){
|
||||
const state=await readMaintenanceRecoveryFacts(stateDirectory);assert(state.kind==='journal'&&state.generation)
|
||||
const accepted=evaluateMaintenanceAcceptances(records,state.journal,state.vault.evidence,target)
|
||||
const unresolvedOperations=state.operations.filter(op=>op.state!=='applied'),vaultCoverage=state.vault.evidence.filter(marker=>state.vault.unresolvedFiles.includes(marker.file))
|
||||
const newUnresolved=unresolvedOperations.some(op=>!accepted.acceptedCoreIds.has(op.operationId))||state.vault.unresolvedFiles.some(file=>!accepted.acceptedVaultFiles.has(file))
|
||||
return {newUnresolved,external:{journalCoverage:{prefixBytes:state.journal.length,sha256:sha(state.journal),unresolvedOperations},vaultCoverage,generationObservedSha256:sha(state.generation),previousResolutionSha256:accepted.headSha256}}
|
||||
}
|
||||
export async function maintenanceMain(args:string[]){
|
||||
assertJsDriverEnvironment(process.env);const resolve=args[0]==='maintenance-resolve';assert(resolve||args[0]==='maintenance-inspect')
|
||||
const required=[...flags,...(resolve?['inspection','inspection-sha256','approval','approval-sha256']:['output'])],names=[...required,'ca','ca-sha256'],values=new Map<string,string>()
|
||||
assert(args.length===1+required.length*2||args.length===1+(required.length+2)*2)
|
||||
for(let i=1;i<args.length;i+=2){const name=args[i].slice(2);assert(args[i].startsWith('--')&&names.includes(name)&&!values.has(name)&&args[i+1]);values.set(name,args[i+1])}
|
||||
for(const name of required)assert(values.has(name));assert(values.has('ca')===values.has('ca-sha256'));const get=(name:string)=>values.get(name)!
|
||||
for(const name of [...values.keys()].filter(name=>name.endsWith('sha256')))assert(hex(get(name)))
|
||||
const stateDirectory=get('state-dir');await protectedDirectory(stateDirectory)
|
||||
const transport=parseMaintenanceJSON(await readProtected(get('transport'),16384,get('transport-sha256')));object(transport);const mode=transport.mode===undefined?'tls':transport.mode;assert(mode==='tls'||mode==='local-loopback');assert(values.has('ca')===(mode==='tls'))
|
||||
const ca=mode==='tls'?await readProtected(get('ca'),65536,get('ca-sha256')):undefined
|
||||
const runtime=parseMaintenanceJSON(await readProtected(get('runtime-target'),16384,get('runtime-target-sha256')));exact(runtime,['format','databaseUrl']);assert(runtime.format==='database-maintenance-runtime-target-v1'&&typeof runtime.databaseUrl==='string');const runtimeUrl=new URL(runtime.databaseUrl);assert(!runtimeUrl.username&&!runtimeUrl.password)
|
||||
const target=runtimeDatabaseTargetSha256(runtime.databaseUrl),{options,transportBinding}=maintenancePoolConfiguration(transport,ca,mode==='tls'?get('ca-sha256'):undefined)
|
||||
exact(transport.connection,['host','port','user','password','database']);assert.equal(decodeURIComponent(runtimeUrl.pathname.slice(1)),transport.connection.database)
|
||||
const records=await readMaintenanceAcceptanceRecords(stateDirectory);if(resolve)assert(records.length<64,'Maintenance acceptance history bound reached');const currentState=await snapshot(stateDirectory,target,records),before=currentState.external;if(resolve)assert(currentState.newUnresolved,'No new unresolved history to accept')
|
||||
await inventoryMigrationDirectory(path.join(path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url)))),'drizzle'))
|
||||
let inspection:Record<string,unknown>|undefined,approved:ReturnType<typeof maintenanceApproval>|undefined
|
||||
if(resolve){inspection=parseMaintenanceJSON(await readProtected(get('inspection'),67108864,get('inspection-sha256'))) as Record<string,unknown>;exact(inspection,['format','createdAt','expiresAt','runtimeTargetSha256','acceptanceRequired','external','knownCurrent','schema']);assert(inspection.format==='database-maintenance-current-state-inspection-v1'&&stamp(inspection.createdAt)&&stamp(inspection.expiresAt)&&Date.parse(inspection.createdAt)<=Date.now()&&Date.now()<Date.parse(inspection.expiresAt)&&Date.parse(inspection.expiresAt)-Date.parse(inspection.createdAt)<=validityMs&&inspection.runtimeTargetSha256===target&&inspection.acceptanceRequired===true);validateMaintenanceKnownCurrent(inspection.knownCurrent);assert.equal(stable(inspection.external),stable(before));approved=maintenanceApproval(parseMaintenanceJSON(await readProtected(get('approval'),16384,get('approval-sha256'))),get('inspection-sha256'),inspection.createdAt)}
|
||||
const {default:pg}=await import('pg');assertJsDriverEnvironment(process.env)
|
||||
const runProof=async()=>{const pool=new pg.Pool(options);let failed=false;pool.on('error',()=>{failed=true});try{const result=await observeMaintenanceCurrent(pool,transportBinding);await pool.end();assert(!failed);return result}catch(error){try{await pool.end()}catch{/* preserve refusal */}throw error}}
|
||||
if(!resolve){const result=await runProof();assert.equal(stable((await snapshot(stateDirectory,target,await readMaintenanceAcceptanceRecords(stateDirectory))).external),stable(before));const nowMs=Date.now(),now=new Date(nowMs).toISOString(),out={format:'database-maintenance-current-state-inspection-v1',createdAt:now,expiresAt:new Date(nowMs+validityMs).toISOString(),runtimeTargetSha256:target,acceptanceRequired:currentState.newUnresolved,external:before,...result};const raw=Buffer.from(stable(out));assert(raw.length<=67108864);await writeFile(get('output'),raw,{flag:'wx',mode:0o600});return {event:'database-maintenance-inspection',inspectionSha256:sha(raw),unresolvedCoreOperations:before.journalCoverage.unresolvedOperations.length,unresolvedVaultFiles:before.vaultCoverage.length,state:currentState.newUnresolved?'inspection-required':before.previousResolutionSha256?'historically-accepted':'no-unresolved-history',historicalAcceptanceHeadSha256:before.previousResolutionSha256,humanApprovalRequired:currentState.newUnresolved,knownCurrentObserved:true,archiveDataEquivalenceProved:false,historicOutcomesChanged:false,automaticRestore:false}}
|
||||
const resolutionDirectory=path.join(stateDirectory,'maintenance-acceptances');await mkdir(resolutionDirectory,{recursive:true,mode:0o700});await protectedDirectory(resolutionDirectory)
|
||||
return withJournalLease(resolutionDirectory,async()=>{
|
||||
// Existing immutable records are reread while our proven O_EXCL lease is held.
|
||||
// Runtime reader refuses this lease; never infer stale ownership from PID/time.
|
||||
const expected=records.map(raw=>{const v=parseMaintenanceJSON(raw) as {resolutionId:string};return v.resolutionId+'.json'}).sort()
|
||||
assert.equal((await readdir(resolutionDirectory)).sort().join('\0'),[...expected,'publisher-lease.lock'].sort().join('\0'))
|
||||
for(let i=0;i<records.length;i++){const value=parseMaintenanceJSON(records[i]) as {resolutionId:string};assert.equal(sha(await readProtected(path.join(resolutionDirectory,value.resolutionId+'.json'),1048576)),sha(records[i]))}
|
||||
const observed=await runProof();assert.equal(stable(observed.knownCurrent),stable(inspection!.knownCurrent));assert.equal(stable(observed.schema),stable(inspection!.schema))
|
||||
assert.equal(stable((await snapshot(stateDirectory,target,records)).external),stable(before));assert(Date.now()<Date.parse(approved!.expiresAt)&&Date.now()<Date.parse(inspection!.expiresAt as string))
|
||||
const record={format:MAINTENANCE_ACCEPTANCE_FORMAT,resolutionId:approved!.resolutionId,decision:'accept-observed-current-state',historicOutcomeChanged:false,automaticRestore:false,actor:{kind:'host-operator',id:approved!.actorId},approvalSha256:get('approval-sha256'),inspectionSha256:get('inspection-sha256'),approvedAt:approved!.issuedAt,approvalExpiresAt:approved!.expiresAt,acceptedAt:new Date().toISOString(),runtimeTargetSha256:target,...before,knownCurrent:observed.knownCurrent}
|
||||
validateMaintenanceAcceptance(record);const raw=Buffer.from(stable(record));assert(raw.length<=1048576,'Maintenance acceptance byte bound exceeded');await writeFile(path.join(resolutionDirectory,record.resolutionId+'.json'),raw,{flag:'wx',mode:0o600})
|
||||
return {event:'database-maintenance-current-state-accepted',outcome:'committed',resolutionId:record.resolutionId,acceptanceSha256:sha(raw),archiveDataEquivalenceProved:false,historicOutcomesChanged:false,automaticRestore:false}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Explicit operator/lifecycle read component. No startup, grants or READY. */
|
||||
import assert from 'node:assert/strict'
|
||||
import {lstat} from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {protectedDirectory,readProtected} from './operator-files.mjs'
|
||||
import {createDatabaseSourceBindingVerifier,readDatabaseSourceBindingRegistry,
|
||||
type DatabaseSourceDeploymentPin} from '../../src/db/database-source-binding.js'
|
||||
import type {DatabaseSourceObservation,DatabaseSourceObservationOptions} from '../../src/db/database-source-observation.js'
|
||||
import type {ProbeStorageSql} from '../../src/services/server-probe-journal-maintenance.js'
|
||||
|
||||
const hex=(value:unknown):value is string=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value)
|
||||
function fence(options:DatabaseSourceObservationOptions){
|
||||
assert(Number.isSafeInteger(options.deadlineAt)&&Date.now()<options.deadlineAt&&!options.signal?.aborted,'Protected source lifetime refused')
|
||||
}
|
||||
async function identity(filename:string,content=false){
|
||||
const s=await lstat(filename,{bigint:true})
|
||||
// BigInt identity is never narrowed to Number. Content changes/permission
|
||||
// changes are checked around the whole verified read, not just open().
|
||||
return [s.dev,s.ino,s.mode,s.uid,s.gid,s.nlink,...(content?[s.size,s.mtimeNs,s.ctimeNs]:[])].map(String).join(':')
|
||||
}
|
||||
async function protectedState(pin:DatabaseSourceDeploymentPin){
|
||||
// Proven POSIX protection refuses unsupported Windows ACL BEFORE file reads.
|
||||
await protectedDirectory(pin.registryDirectory)
|
||||
const directories:Record<string,string>={}
|
||||
for(let p=pin.registryDirectory;;p=path.dirname(p)){
|
||||
directories[p]=await identity(p,p===pin.registryDirectory)
|
||||
if(p===path.dirname(p))break
|
||||
}
|
||||
const names=['operator-public.pem','head-'+pin.currentHeadSha256+'.json']
|
||||
const content:Record<string,Buffer>={},identities:Record<string,string>={}
|
||||
const collect=async(name:string,maximum:number,expected?:string)=>{
|
||||
const filename=path.join(pin.registryDirectory,name)
|
||||
const before=await identity(filename,true)
|
||||
content[name]=await readProtected(filename,maximum,expected)
|
||||
assert.equal(await identity(filename,true),before,'Protected source file changed during read')
|
||||
identities[name]=before
|
||||
}
|
||||
await collect(names[0],4096)
|
||||
await collect(names[1],8192,pin.currentHeadSha256)
|
||||
const head=JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(content[names[1]]))
|
||||
assert(hex(head?.body?.bindingSha256),'Pinned source binding digest required')
|
||||
const bindingName='binding-'+head.body.bindingSha256+'.json'
|
||||
await collect(bindingName,8192,head.body.bindingSha256)
|
||||
await protectedDirectory(pin.registryDirectory)
|
||||
for(const [directory,wanted]of Object.entries(directories))assert.equal(await identity(directory,directory===pin.registryDirectory),wanted,'Protected source directory changed')
|
||||
return {directories,identities,content}
|
||||
}
|
||||
function same(before:Awaited<ReturnType<typeof protectedState>>,after:Awaited<ReturnType<typeof protectedState>>){
|
||||
assert.deepEqual(after.directories,before.directories,'Protected source directory drift')
|
||||
assert.deepEqual(after.identities,before.identities,'Protected source identity/mode drift')
|
||||
assert.deepEqual(after.content,before.content,'Protected source content drift')
|
||||
}
|
||||
function captured(raw:DatabaseSourceDeploymentPin){
|
||||
const pin=Object.freeze({...raw})
|
||||
// Existing strict deployment schema rejects unknown/malformed pin fields.
|
||||
const verifier=createDatabaseSourceBindingVerifier(pin)
|
||||
return {pin,verifier}
|
||||
}
|
||||
|
||||
/** Signature/head/source DATA plus actual POSIX protection of the pinned files.
|
||||
* The supplied pin still needs independent deployment approval; protection
|
||||
* cannot distinguish a restored clone/generation or authenticate all writers. */
|
||||
export async function readProtectedDatabaseSourceBindingRegistry(raw:DatabaseSourceDeploymentPin,options:DatabaseSourceObservationOptions){
|
||||
const {pin}=captured(raw);const lifetime={...options};fence(lifetime)
|
||||
const before=await protectedState(pin);fence(lifetime)
|
||||
const result=await readDatabaseSourceBindingRegistry(pin);fence(lifetime)
|
||||
const after=await protectedState(pin);fence(lifetime);same(before,after)
|
||||
return Object.freeze({...result,platformProtectionVerified:true as const,
|
||||
generationAuthenticated:false as const,allWriterDrainAuthenticated:false as const,readyAuthorized:false as const})
|
||||
}
|
||||
|
||||
/** Same-facade source verification is retained; filesystem JSON is never a
|
||||
* source observation, writer capability or independent generation authority. */
|
||||
export function createProtectedDatabaseSourceBindingVerifier(raw:DatabaseSourceDeploymentPin){
|
||||
const {pin,verifier}=captured(raw)
|
||||
return Object.freeze({async verify(sql:ProbeStorageSql,observation:DatabaseSourceObservation,options:DatabaseSourceObservationOptions){
|
||||
const lifetime={...options};fence(lifetime)
|
||||
const before=await protectedState(pin);fence(lifetime)
|
||||
const result=await verifier.verify(sql,observation,lifetime);fence(lifetime)
|
||||
const after=await protectedState(pin);fence(lifetime);same(before,after)
|
||||
return Object.freeze({...result,platformProtectionVerified:true as const,
|
||||
generationAuthenticated:false as const,allWriterDrainAuthenticated:false as const,readyAuthorized:false as const})
|
||||
}})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/** 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)
|
||||
}))
|
||||
@@ -141,14 +141,14 @@ async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){
|
||||
const first=await ownedContext(s,installation,PUBLISHER,'publisher-read')
|
||||
if(canonical(sourceCoordinates(first))!==canonical(installation.expectedSource))throw Error('Pinned preflight source mismatch')
|
||||
const facade=Object.freeze({query:(sql,values)=>s.query(sql,values)}),options={deadlineAt}
|
||||
const observation=await captureDatabaseSourceObservation(facade,options);await sourceVerifier.verify(facade,observation,options)
|
||||
const observation=await captureDatabaseSourceObservation(facade,options),initialSource=await sourceVerifier.verify(facade,observation,options)
|
||||
const security=await protectedStructure(s,installation),catalogue=await collectFixedCatalogue(s)
|
||||
const current=await readCurrent(s,installation)
|
||||
if(!current)throw Error('Current signed policy required for preflight')
|
||||
const catalogSha256=sha(canonical({base:catalogue.catalogSha256,security:security.sha256})),manifestSha256=migrationManifestDigest()
|
||||
if(approval.expiresAt<=Date.now()||approval.manifestSha256!==manifestSha256||approval.definitionSha256!==catalogue.definitionSha256||approval.catalogSha256!==catalogSha256)throw Error('Independent preflight approval differs from actual source44 catalogue')
|
||||
if(canonical(current.policy.source)!==canonical(sourceCoordinates(first))||canonical(current.policy.roles)!==canonical(security.roles)||current.policy.manifestSha256!==manifestSha256||current.policy.definitionSha256!==catalogue.definitionSha256||current.policy.catalogSha256!==catalogSha256)throw Error('Current signed policy differs from actual source/roles/catalogue')
|
||||
await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options)
|
||||
const terminalSource=await sourceVerifier.verify(facade,observation,options);await assertDatabaseSourceObservationCurrent(facade,observation,options)
|
||||
sameOwned(first,await ownedContext(s,installation,PUBLISHER,'publisher-read'))
|
||||
// Recheck the live interval after the potentially lengthy catalogue walk.
|
||||
const clock=(await s.query('SELECT (EXTRACT(EPOCH FROM pg_catalog.clock_timestamp())*1000)::bigint::text AS now')).rows
|
||||
@@ -157,7 +157,7 @@ async function preflightCurrentProfile(pool,{deadlineAt=Date.now()+120000}={}){
|
||||
if(approval.expiresAt<=Date.now())throw Error('Preflight approval expired before terminal')
|
||||
const terminal=await s.query('ROLLBACK');if(terminal.command!=='ROLLBACK')throw Error('Preflight terminal unconfirmed');s.begun(false)
|
||||
return Object.freeze({state:'matched',epoch:current.epoch,headSha256:current.headSha256,notAfter:current.policy.notAfter,
|
||||
sourceMatched:true,rolesMatched:true,catalogueMatched:true,writerAuthorized:false,generationAuthenticated:false,readyAuthorized:false})
|
||||
sourceMatched:true,rolesMatched:true,catalogueMatched:true,platformProtectionVerified:initialSource?.platformProtectionVerified===true&&terminalSource?.platformProtectionVerified===true,writerAuthorized:false,generationAuthenticated:false,readyAuthorized:false})
|
||||
})
|
||||
}
|
||||
async function boundedPending(filename){
|
||||
|
||||
Reference in new issue
Block a user