Files
MikrotikManager/backend/src/db/application-writer-profile-format.ts
T

57 lines
5.0 KiB
TypeScript

import {createPublicKey,verify,type KeyObject} from 'node:crypto'
import {z} from 'zod'
import {bytes,canonical,sha} from './application-writer-profile-canonical.js'
export {bytes,canonical,sha}
// Internal schema/crypto only: no key loader, DB/CLI/runtime, installation issuer,
// caller-JSON pin parser, provider, source/current-head proof or writer capability.
// A verified signature under a supplied public key is DATA, never authorization.
export const KEY=8723110,PUBLISHER='barrier_profile_publisher',OPERATOR='barrier_operator'
export const POLICY_DOMAIN='mmapp:application-writer-policy:ed25519:v1\n',HEAD_DOMAIN='mmapp:application-writer-head:ed25519:v1\n'
export const MAX_EPOCH=9223372036854775807n
export function epoch(value:unknown):string {
if(typeof value!=='string'||!(/^[1-9][0-9]{0,18}$/).test(value)||BigInt(value)>MAX_EPOCH)throw Error('Canonical PG bigint epoch required')
return value
}
export function nextEpoch(value:unknown):string {
const next=BigInt(epoch(value))+1n
if(next>MAX_EPOCH)throw Error('PG bigint epoch exhausted')
return next.toString()
}
const hex=z.string().regex(/^[a-f0-9]{64}$/),uuid=z.string().uuid(),decimal=z.string().refine(x=>{try{return epoch(x)===x}catch{return false}})
const oid=z.string().regex(/^[1-9][0-9]{0,9}$/).refine(value=>BigInt(value)<=4294967295n)
const roles=z.object({runtime:oid,operator:oid,capture:oid,publisher:oid,control:oid}).strict()
const source=z.object({systemIdentifier:z.string().regex(/^[1-9][0-9]{0,19}$/).refine(value=>BigInt(value)<=18446744073709551615n),databaseOid:oid,databaseName:z.string().min(1).max(63),serverVersionNum:z.number().int().min(180000).max(189999),inRecovery:z.literal(false)}).strict()
export const policySchema=z.object({format:z.literal('application-writer-policy-v1'),installationId:uuid,epoch:decimal,issuedAt:z.number().int().nonnegative(),notBefore:z.number().int().nonnegative(),notAfter:z.number().int().nonnegative(),source,sourceObservationSha256:hex,roles,manifestSha256:hex,definitionSha256:hex,catalogSha256:hex,futureLeaves:z.literal('fixed12-native-policy-v1')}).strict().refine(p=>p.notBefore===p.issuedAt&&p.notAfter>p.notBefore&&p.notAfter-p.notBefore<=86400000)
export const headSchema=z.object({format:z.literal('application-writer-head-v1'),installationId:uuid,epoch:decimal,policySha256:hex,previousHeadSha256:hex.nullable()}).strict().refine(p=>(p.epoch==='1')===(p.previousHeadSha256===null))
const envelope=z.object({body:z.unknown(),signature:z.string().regex(/^[A-Za-z0-9_-]{86}$/)}).strict()
export type ApplicationWriterPolicy=z.infer<typeof policySchema>
export type ApplicationWriterHead=z.infer<typeof headSchema>
type ArtifactKind='policy'|'head'
// Not exported as a provisioning/config API. Future private host must obtain
// this subject from protected installation state, never a request or provider.
interface PublicVerificationSubject {readonly installationId:string;readonly publicKeyPem:string;readonly publicKeySpkiSha256:string}
type ParsedData<T>={body:T;sha256:string}
export function publicKey(installation:PublicVerificationSubject):KeyObject {
const key=createPublicKey(installation.publicKeyPem)
if(key.asymmetricKeyType!=='ed25519'||sha(key.export({format:'der',type:'spki'}))!==installation.publicKeySpkiSha256)throw Error('Fixed installation SPKI required')
return key
}
export function signedEnvelope(raw:Uint8Array|string,kind:'policy',installation:PublicVerificationSubject):ParsedData<ApplicationWriterPolicy>
export function signedEnvelope(raw:Uint8Array|string,kind:'head',installation:PublicVerificationSubject):ParsedData<ApplicationWriterHead>
export function signedEnvelope(raw:Uint8Array|string,kind:ArtifactKind,installation:PublicVerificationSubject):ParsedData<ApplicationWriterPolicy|ApplicationWriterHead>
export function signedEnvelope(raw:Uint8Array|string,kind:ArtifactKind,installation:PublicVerificationSubject):ParsedData<ApplicationWriterPolicy|ApplicationWriterHead> {
const data=Buffer.from(raw)
if(data.length<1||data.length>(kind==='policy'?1048576:8192))throw Error('Signed artifact bound')
const value=envelope.parse(JSON.parse(new TextDecoder('utf8',{fatal:true}).decode(data)))
if(!bytes(value).equals(data))throw Error('Canonical signed artifact required')
if(Buffer.from(value.signature,'base64url').toString('base64url')!==value.signature)throw Error('Canonical signature encoding required')
const body=(kind==='policy'?policySchema:headSchema).parse(value.body)
if(body.installationId!==installation.installationId||!verify(null,Buffer.from((kind==='policy'?POLICY_DOMAIN:HEAD_DOMAIN)+canonical(body)),publicKey(installation),Buffer.from(value.signature,'base64url')))throw Error('Installation signature refused')
return {body,sha256:sha(data)}
}
export function liveValidity(policy:ApplicationWriterPolicy,serverNow:number):void {
const now=Date.now()
if(!Number.isSafeInteger(serverNow)||Math.abs(serverNow-now)>5000||now<policy.notBefore||now>=policy.notAfter||serverNow<policy.notBefore||serverNow>=policy.notAfter)throw Error('Current signed policy interval/clock refused')
}