113 lines
8.9 KiB
TypeScript
113 lines
8.9 KiB
TypeScript
import { createHash, createPublicKey, verify } from "node:crypto"
|
|
import { constants } from "node:fs"
|
|
import { lstat, open, realpath } from "node:fs/promises"
|
|
import path from "node:path"
|
|
import { z } from "zod"
|
|
import { assertDatabaseSourceObservationCurrent, type DatabaseSourceObservation, type DatabaseSourceObservationOptions } from "./database-source-observation.js"
|
|
import type { ProbeStorageSql } from "../services/server-probe-journal-maintenance.js"
|
|
|
|
// Public verification only. No private key, signing, enrollment, SQL mutation,
|
|
// configured pool, provisioning or automatic current-head advancement here.
|
|
export class DatabaseSourceBindingError extends Error { readonly code = "DATABASE_SOURCE_BINDING_REFUSED" }
|
|
const fail = (): never => { throw new DatabaseSourceBindingError("Доверенная подпись и текущая привязка источника БД не подтверждены") }
|
|
const sha = z.string().regex(/^[a-f0-9]{64}$/)
|
|
const uuid = z.string().uuid()
|
|
const text = z.string().min(1).max(200).regex(/^[^\x00-\x1f\x7f]+$/)
|
|
const source = z.object({ systemIdentifier: z.string().regex(/^[1-9][0-9]{0,19}$/).refine(value => BigInt(value) <= 18446744073709551615n),
|
|
databaseOid: z.number().int().positive().max(4294967295), databaseName: text, sessionUser: text, currentUser: text,
|
|
inRecovery: z.literal(false), serverVersionNum: z.number().int().min(180000).max(189999) }).strict()
|
|
export const sourceBindingBodySchema = z.object({ format: z.literal("database-source-binding-v1"), installationId: uuid,
|
|
sourceIdentityId: uuid, generation: uuid, ordinal: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), source }).strict()
|
|
export const sourceRegistryHeadSchema = z.object({ format: z.literal("database-source-registry-head-v1"), installationId: uuid,
|
|
sourceIdentityId: uuid, generation: uuid, ordinal: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), bindingSha256: sha,
|
|
previousHeadSha256: sha.nullable() }).strict().refine(value => (value.ordinal === 1) === (value.previousHeadSha256 === null))
|
|
const deployment = z.object({ registryDirectory: z.string().min(1).max(4096), installationId: uuid, publicKeySpkiSha256: sha, currentHeadSha256: sha }).strict()
|
|
const envelope = z.object({ body: z.unknown(), signature: z.string().regex(/^[A-Za-z0-9_-]{86}$/) }).strict()
|
|
export type DatabaseSourceBindingBody = z.infer<typeof sourceBindingBodySchema>
|
|
export type DatabaseSourceRegistryHead = z.infer<typeof sourceRegistryHeadSchema>
|
|
export type DatabaseSourceDeploymentPin = z.infer<typeof deployment>
|
|
export const DATABASE_SOURCE_SIGNATURE_DOMAINS = Object.freeze({ binding: "mmapp:database-source-binding:ed25519:v1\n", head: "mmapp:database-source-head:ed25519:v1\n" })
|
|
|
|
export function sourceBindingCanonical(value: unknown): string {
|
|
return JSON.stringify(value, (_key, item: unknown) => item && typeof item === "object" && !Array.isArray(item)
|
|
? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item)
|
|
}
|
|
export const sourceBindingSha256 = (bytes: Uint8Array | string) => createHash("sha256").update(bytes).digest("hex")
|
|
export const sourceBindingBytes = (value: unknown) => Buffer.from(sourceBindingCanonical(value) + "\n", "utf8")
|
|
function parseExact(bytes: Buffer) {
|
|
const value: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))
|
|
// Exact canonical bytes also reject duplicate JSON keys, alternate numeric
|
|
// spellings, trailing content and ambiguous/noncanonical Unicode encodings.
|
|
if (!sourceBindingBytes(value).equals(bytes)) fail()
|
|
return envelope.parse(value)
|
|
}
|
|
async function boundedFile(filename: string, maximum = 8192): Promise<Buffer> {
|
|
const before = await lstat(filename)
|
|
if (!before.isFile() || before.isSymbolicLink() || before.size < 1 || before.size > maximum) fail()
|
|
const handle = await open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
|
|
try {
|
|
const stat = await handle.stat()
|
|
if (!stat.isFile() || stat.dev !== before.dev || stat.ino !== before.ino || stat.size !== before.size) fail()
|
|
const bytes = Buffer.alloc(stat.size + 1); let length = 0
|
|
while (length < bytes.length) { const got = await handle.read(bytes, length, bytes.length - length, length); if (!got.bytesRead) break; length += got.bytesRead }
|
|
const after = await handle.stat()
|
|
if (length !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) fail()
|
|
return bytes.subarray(0, length)
|
|
} finally { await handle.close() }
|
|
}
|
|
function fence(options: DatabaseSourceObservationOptions) {
|
|
if (!Number.isSafeInteger(options.deadlineAt) || options.deadlineAt <= Date.now() || options.signal?.aborted) fail()
|
|
}
|
|
|
|
/** Input is privileged deployment configuration, never request JSON. Actual
|
|
* protection/ACL and current-process invalidation are separate deployment gates.
|
|
* This deliberately grants signature/source evidence only, NOT generation/READY. */
|
|
export async function readDatabaseSourceBindingRegistry(raw: DatabaseSourceDeploymentPin) {
|
|
try {
|
|
const cfg = deployment.parse(raw), directory = path.resolve(cfg.registryDirectory)
|
|
if (!path.isAbsolute(cfg.registryDirectory) || directory !== await realpath(directory)) fail()
|
|
const stat = await lstat(directory); if (!stat.isDirectory() || stat.isSymbolicLink()) fail()
|
|
const publicBytes = await boundedFile(path.join(directory, "operator-public.pem"), 4096)
|
|
if (publicBytes.toString("ascii").includes("PRIVATE KEY")) fail()
|
|
const publicKey = createPublicKey(publicBytes)
|
|
if (publicKey.type !== "public" || publicKey.asymmetricKeyType !== "ed25519") fail()
|
|
const spki = publicKey.export({ type: "spki", format: "der" })
|
|
if (sourceBindingSha256(spki) !== cfg.publicKeySpkiSha256) fail()
|
|
const headBytes = await boundedFile(path.join(directory, "head-" + cfg.currentHeadSha256 + ".json"))
|
|
if (sourceBindingSha256(headBytes) !== cfg.currentHeadSha256) fail()
|
|
const signedHead = parseExact(headBytes), head = sourceRegistryHeadSchema.parse(signedHead.body)
|
|
const check = (domain: string, body: unknown, signature: string) => verify(null, Buffer.from(domain + sourceBindingCanonical(body)), publicKey, Buffer.from(signature, "base64url"))
|
|
if (head.installationId !== cfg.installationId || !check(DATABASE_SOURCE_SIGNATURE_DOMAINS.head, head, signedHead.signature)) fail()
|
|
const bindingBytes = await boundedFile(path.join(directory, "binding-" + head.bindingSha256 + ".json"))
|
|
if (sourceBindingSha256(bindingBytes) !== head.bindingSha256) fail()
|
|
const signedBinding = parseExact(bindingBytes), body = sourceBindingBodySchema.parse(signedBinding.body)
|
|
if (!check(DATABASE_SOURCE_SIGNATURE_DOMAINS.binding, body, signedBinding.signature) || body.installationId !== head.installationId ||
|
|
body.sourceIdentityId !== head.sourceIdentityId || body.generation !== head.generation || body.ordinal !== head.ordinal) fail()
|
|
return Object.freeze({ body: Object.freeze({ ...body, source: Object.freeze({ ...body.source }) }),
|
|
head: Object.freeze(head), publicKeySpkiSha256: cfg.publicKeySpkiSha256, headSha256: cfg.currentHeadSha256,
|
|
platformProtectionVerified: false as const, generationAuthenticated: false as const, readyAuthorized: false as const })
|
|
} catch { return fail() }
|
|
}
|
|
|
|
/** Genuine same-facade observation is mandatory. Serialized source facts never
|
|
* become a capability. Registry pin comes only from future privileged assembly. */
|
|
export function createDatabaseSourceBindingVerifier(raw: DatabaseSourceDeploymentPin) {
|
|
const cfg = Object.freeze(deployment.parse(raw))
|
|
return Object.freeze({ async verify(sql: ProbeStorageSql, observation: DatabaseSourceObservation, options: DatabaseSourceObservationOptions) {
|
|
try {
|
|
fence(options); await assertDatabaseSourceObservationCurrent(sql, observation, options)
|
|
const before = await readDatabaseSourceBindingRegistry(cfg); fence(options)
|
|
if (sourceBindingCanonical(before.body.source) !== sourceBindingCanonical(observation.source)) fail()
|
|
await assertDatabaseSourceObservationCurrent(sql, observation, options); fence(options)
|
|
const after = await readDatabaseSourceBindingRegistry(cfg); fence(options)
|
|
if (sourceBindingCanonical(before) !== sourceBindingCanonical(after)) fail()
|
|
await assertDatabaseSourceObservationCurrent(sql, observation, options); fence(options)
|
|
return Object.freeze({ format: "database-source-binding-evidence-v1" as const,
|
|
installationId: before.body.installationId, sourceIdentityId: before.body.sourceIdentityId, generation: before.body.generation,
|
|
ordinal: before.body.ordinal, headSha256: before.headSha256, bindingSha256: before.head.bindingSha256,
|
|
operatorSignatureVerified: true as const, currentHeadPinVerified: true as const, genuineSourceFactsMatched: true as const,
|
|
platformProtectionVerified: false as const, generationAuthenticated: false as const, allWriterDrainAuthenticated: false as const, readyAuthorized: false as const })
|
|
} catch { return fail() }
|
|
} })
|
|
}
|