Files
MikrotikManager/backend/scripts/database-source-binding-operator.ts
T

75 lines
6.0 KiB
TypeScript

/** Offline operator-only issuer proposal. Never imported by runtime/startup.
* Effective key/registry ACL isolation is required before deployment acceptance;
* mode bits and possession of CLI arguments do not establish that isolation. */
import { createPrivateKey, createPublicKey, sign } from "node:crypto"
import { mkdir, open, realpath } from "node:fs/promises"
import path from "node:path"
import { z } from "zod"
import { DATABASE_SOURCE_SIGNATURE_DOMAINS, sourceBindingBodySchema, sourceBindingBytes, sourceBindingCanonical, sourceBindingSha256,
readDatabaseSourceBindingRegistry, type DatabaseSourceDeploymentPin } from "../src/db/database-source-binding.js"
const requestSchema = sourceBindingBodySchema.omit({ format: true, ordinal: true })
export type DatabaseSourceOperatorRequest = z.infer<typeof requestSchema>
function outside(parent: string, child: string) {
const relative = path.relative(parent, child)
return !!relative && (relative.startsWith(".." + path.sep) || relative === ".." || path.isAbsolute(relative))
}
export async function provisionDatabaseSourceBindingOffline(options: { request: DatabaseSourceOperatorRequest; privateKeyFile: string;
outputDirectory: string; previousPin?: DatabaseSourceDeploymentPin }) {
const request = requestSchema.parse(options.request)
const privatePath = await realpath(options.privateKeyFile), output = path.resolve(options.outputDirectory)
const parent = await realpath(path.dirname(output))
if (parent !== path.dirname(output) || !outside(output, privatePath)) throw Error("Private operator key must be outside new registry, without ambiguous parent links")
const stat = await open(privatePath, "r")
let privateBytes: Buffer
try { const meta = await stat.stat(); if (!meta.isFile() || meta.size < 1 || meta.size > 8192) throw Error("Bounded operator private key required"); privateBytes = await stat.readFile() }
finally { await stat.close() }
const privateKey = createPrivateKey(privateBytes); privateBytes.fill(0)
if (privateKey.type !== "private" || privateKey.asymmetricKeyType !== "ed25519") throw Error("Independent Ed25519 operator key required")
const publicKey = createPublicKey(privateKey), spki = publicKey.export({ type: "spki", format: "der" })
const publicKeySpkiSha256 = sourceBindingSha256(spki)
const previous = options.previousPin ? await readDatabaseSourceBindingRegistry(options.previousPin) : undefined
if (previous && (previous.body.installationId !== request.installationId || previous.body.sourceIdentityId !== request.sourceIdentityId ||
previous.publicKeySpkiSha256 !== publicKeySpkiSha256 || previous.body.generation === request.generation || previous.body.ordinal >= Number.MAX_SAFE_INTEGER)) throw Error("Previous current head/identity/key or distinct new generation required")
const body = sourceBindingBodySchema.parse({ ...request, format: "database-source-binding-v1", ordinal: previous ? previous.body.ordinal + 1 : 1 })
const signed = (domain: string, payload: unknown) => sourceBindingBytes({ body: payload,
signature: sign(null, Buffer.from(domain + sourceBindingCanonical(payload)), privateKey).toString("base64url") })
const bindingBytes = signed(DATABASE_SOURCE_SIGNATURE_DOMAINS.binding, body), bindingSha256 = sourceBindingSha256(bindingBytes)
const head = { format: "database-source-registry-head-v1", installationId: body.installationId, sourceIdentityId: body.sourceIdentityId,
generation: body.generation, ordinal: body.ordinal, bindingSha256, previousHeadSha256: previous?.headSha256 ?? null }
const headBytes = signed(DATABASE_SOURCE_SIGNATURE_DOMAINS.head, head), currentHeadSha256 = sourceBindingSha256(headBytes)
// No recursive mkdir and no existing-state adoption. A partial output remains
// for explicit operator review; uncertainty is never retried/overwritten.
await mkdir(output, { mode: 0o700 })
const publish = async (filename: string, bytes: Buffer | string) => {
const handle = await open(path.join(output, filename), "wx", 0o600)
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
}
await publish("operator-public.pem", publicKey.export({ type: "spki", format: "pem" }))
await publish("binding-" + bindingSha256 + ".json", bindingBytes)
await publish("head-" + currentHeadSha256 + ".json", headBytes)
const deploymentPin = { registryDirectory: output, installationId: body.installationId, publicKeySpkiSha256, currentHeadSha256 }
// Public review artifact only; not installed into any application config.
await publish("proposed-deployment-pin.json", sourceBindingBytes(deploymentPin))
return Object.freeze({ deploymentPin: Object.freeze(deploymentPin), keyIsolationVerified: false as const,
platformProtectionVerified: false as const, generationAuthenticated: false as const, readyAuthorized: false as const })
}
export async function databaseSourceBindingOperatorCli(args: string[]) {
const allowed = new Set(["request", "private-key", "output", "previous-pin"]), values = new Map<string, string>()
for (const arg of args) {
const match = /^--([a-z-]+)=(.+)$/.exec(arg)
if (!match || !allowed.has(match[1]) || values.has(match[1])) throw Error("Exact explicit offline operator arguments required")
values.set(match[1], match[2])
}
if (!["request", "private-key", "output"].every(key => values.has(key))) throw Error("Missing explicit offline operator arguments")
const boundedJson = async (filename: string) => {
const handle = await open(filename, "r")
try { const stat = await handle.stat(); if (!stat.isFile() || stat.size < 1 || stat.size > 8192) throw Error("Bounded operator input required"); return JSON.parse(await handle.readFile("utf8")) as unknown }
finally { await handle.close() }
}
const request = requestSchema.parse(await boundedJson(values.get("request")!))
const previousPin = values.has("previous-pin") ? await boundedJson(values.get("previous-pin")!) as DatabaseSourceDeploymentPin : undefined
return provisionDatabaseSourceBindingOffline({ request, privateKeyFile: values.get("private-key")!, outputDirectory: values.get("output")!, previousPin })
}