fix(certificates): заменять сертификат уникальным поколением

This commit is contained in:
Denozordec committed 2026-10-15 01:32:21 +07:00
1 parent c683e1b2c1
commit 8c5cfa0706
11 files changed
+220 -19

No files matched your search

+14 -8
View File
@@ -1,6 +1,6 @@
import { lookup } from "node:dns/promises"
import { X509Certificate } from "node:crypto"
import { applyVerifiedCertificateToServices } from "./certificate-installation.js"
import { X509Certificate, randomBytes } from "node:crypto"
import { assertLogicalCertificateName, rotateCertificateGeneration } from "./certificate-installation.js"
import * as acme from "acme-client"
import type { Server } from "../db/schema.js"
import {
@@ -252,6 +252,9 @@ export async function issueCertificateWithCloudflareDns(params: {
const domains = [...new Set(params.domainNames.map((d) => d.trim().toLowerCase()).filter(Boolean))]
if (domains.length === 0) throw new Error("Нужен хотя бы один домен")
const clientRos = MikrotikClient.fromServer(params.server)
assertLogicalCertificateName(await clientRos.get<unknown>("/certificate"),params.certName)
params.onStep?.("acme_order")
const accountKey = await getOrCreateAccountKey()
const client = new acme.Client({
@@ -300,7 +303,6 @@ export async function issueCertificateWithCloudflareDns(params: {
const certPem = await client.getCertificate(finalized)
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
const clientRos = MikrotikClient.fromServer(params.server)
try {
params.onStep?.("dns_a_sync")
const serverIp = await resolveServerPublicIp(params.server, clientRos)
@@ -328,6 +330,9 @@ export async function issueCertificateWithCloudflareDns(params: {
const safeBase = params.certName.replace(/[^a-zA-Z0-9._-]+/g, "_")
const chain = splitPemCertificates(certPem)
const leafPem = chain[0] ?? certPem
const expectedFingerprint = new X509Certificate(leafPem).fingerprint256
const generationName = `${safeBase.slice(0,40)}_new_${randomBytes(6).toString("hex")}`
const archiveName = `${safeBase.slice(0,40)}_old_${randomBytes(6).toString("hex")}`
const chainRest = chain.slice(1)
const certFile = `${safeBase}.crt`
const keyFile = `${safeBase}.key`
@@ -335,13 +340,13 @@ export async function issueCertificateWithCloudflareDns(params: {
const keyRouterFile = await clientRos.uploadTextFile(keyFile, privateKey.toString("utf8"))
await clientRos.importCertificate({
fileName: certRouterFile,
name: params.certName,
name: generationName,
trusted: true,
trustStore: trustStoreCsv,
})
await clientRos.importCertificate({
fileName: keyRouterFile,
name: params.certName,
name: generationName,
trusted: true,
trustStore: trustStoreCsv,
})
@@ -359,11 +364,12 @@ export async function issueCertificateWithCloudflareDns(params: {
})
}
await applyVerifiedCertificateToServices({
await rotateCertificateGeneration({
certificates: () => clientRos.get<unknown>("/certificate"),
apply: () => clientRos.applyCertificateToServices(params.certName),
apply: name => clientRos.applyCertificateToServices(name),
services: () => clientRos.get<unknown>("/ip/service"),
}, params.certName, new X509Certificate(leafPem).fingerprint256)
rename: (id,name) => clientRos.patch(`/certificate/${id}`,{name}).then(() => undefined),
}, params.certName, generationName, archiveName, expectedFingerprint)
} finally {
params.onStep?.("cleanup")
for (const item of txtCleanups) {
@@ -1,5 +1,5 @@
import assert from "node:assert/strict"
import { applyVerifiedCertificateToServices } from "./certificate-installation.js"
import { applyVerifiedCertificateToServices,rotateCertificateGeneration } from "./certificate-installation.js"
const fresh={name:"own",fingerprint:"b".repeat(64),"private-key":"true"}
const services=[{name:"www-ssl",certificate:"own",disabled:"false"},{name:"api-ssl",certificate:"own",disabled:"false"}]
for(const value of [{},[],[fresh,fresh],[{...fresh,fingerprint:"a".repeat(64)}],[{...fresh,"private-key":undefined}],[{...fresh,"private-key":"false",flags:"K"}]]) {
@@ -20,3 +20,53 @@ for(const value of [[],[services[0]],services.map(row=>({...row,disabled:"true"}
assert.equal(applied,1)
}
console.log("Certificate installation: 6 invalid imports never apply; 4 service mismatches and post-apply ambiguity never succeed; confirmed import/apply succeeds")
function rotationFixture(withPrevious=true) {
const original={".id":"*A",name:"own",fingerprint:"a".repeat(64),"private-key":"true"}
const candidate={...fresh,".id":"*B",name:"own_new"}
const certs:Record<string,string>[]=[...(withPrevious?[original]:[]),candidate]
const bindings=services.map(row=>({...row})),calls:string[]=[]
let fail=""
const deps={certificates:async()=>certs.map(row=>({...row})),services:async()=>bindings.map(row=>({...row})),
apply:async(name:string)=>{calls.push('apply:'+name);if(fail==='apply:'+name)throw Error('apply refused');for(const row of bindings){row.certificate=name;row.disabled='false'}},
rename:async(id:string,name:string)=>{calls.push('rename:'+name);if(fail==='rename:'+name)throw Error('rename refused');const row=certs.find(row=>row['.id']===id);assert(row);row.name=name}}
return{deps,certs,bindings,calls,original,candidate,setFail:(value:string)=>{fail=value}}
}
for(const previous of[true,false]) {
const f=rotationFixture(previous)
await rotateCertificateGeneration(f.deps,'own','own_new','own_old',fresh.fingerprint)
assert.equal(f.certs.filter(row=>row.name==='own').length,1);assert.equal(f.candidate.name,'own');assert(f.bindings.every(row=>row.certificate==='own'))
if(previous){assert.equal(f.original.name,'own_old');assert.equal(f.original['private-key'],'true');assert.equal(f.original.fingerprint,'a'.repeat(64));assert(f.calls.indexOf('apply:own_new')<f.calls.indexOf('rename:own_old'))}
}
for(const failure of['apply:own_new','rename:own_old','rename:own','apply:own']) {
const f=rotationFixture();f.setFail(failure)
await assert.rejects(()=>rotateCertificateGeneration(f.deps,'own','own_new','own_old',fresh.fingerprint))
assert(f.certs.includes(f.original));assert.equal(f.original['private-key'],'true');assert.equal(f.original.fingerprint,'a'.repeat(64))
if(failure==='apply:own_new')assert.equal(f.original.name,'own')
}
for(const problem of['duplicate-original','occupied-archive','missing-native-id']) {
const f=rotationFixture()
if(problem==='duplicate-original')f.certs.push({...f.original,'.id':'*C'})
if(problem==='occupied-archive')f.certs.push({...f.original,name:'own_old','.id':'*C'})
if(problem==='missing-native-id')delete f.candidate['.id']
await assert.rejects(()=>rotateCertificateGeneration(f.deps,'own','own_new','own_old',fresh.fingerprint));assert.deepEqual(f.calls,[])
}
console.log('Generation rotation: first issue/renewal keep one logical name; old key retained on success and four failure checkpoints; three conflicts never apply')
{
const f=rotationFixture();f.candidate['.id']=f.original['.id']
await assert.rejects(()=>rotateCertificateGeneration(f.deps,'own','own_new','own_old',fresh.fingerprint));assert.deepEqual(f.calls,[])
}
{
const f=rotationFixture(),rename=f.deps.rename
f.deps.rename=async(id,name)=>{if(name==='own_old')return;await rename(id,name)}
await assert.rejects(()=>rotateCertificateGeneration(f.deps,'own','own_new','own_old',fresh.fingerprint),/не подтверждено/)
assert.equal(f.original.name,'own');assert.equal(f.candidate.name,'own_new')
}
{
const f=rotationFixture(),read=f.deps.certificates;let count=0
f.deps.certificates=async()=>{if(++count===4)f.original.fingerprint='c'.repeat(64);return read()}
await assert.rejects(()=>rotateCertificateGeneration(f.deps,'own','own_new','own_old',fresh.fingerprint),/изменился/)
assert.equal(f.original.name,'own')
}
console.log('Rotation rechecks native identities and archive ACK; shared IDs, external changes and false rename ACK cannot create a successful receipt')
@@ -8,7 +8,7 @@ function fingerprint(value: unknown): string | null {
const normalized = value.replaceAll(":", "").toLowerCase()
return /^[a-f0-9]{64}$/.test(normalized) ? normalized : null
}
export function assertImportedCertificate(value: unknown, name: string, expectedFingerprint: string): void {
export function assertImportedCertificate(value: unknown, name: string, expectedFingerprint: string): Row {
const matches = rows(value).filter(row => row.name === name)
if (matches.length !== 1) throw new Error("Импорт не подтверждён: имя сертификата отсутствует или неоднозначно. Назначение сервисам не подтверждено.")
const row = matches[0]!
@@ -16,6 +16,10 @@ export function assertImportedCertificate(value: unknown, name: string, expected
if (!expected || fingerprint(row.fingerprint) !== expected) throw new Error("Импорт не подтверждён: отпечаток сертификата отличается от выданного")
const privateKey = row["private-key"] === "true" || row["private-key"] === "yes" || row["private-key"] === undefined && typeof row.flags === "string" && row.flags.toUpperCase().includes("K")
if (!privateKey) throw new Error("Импорт не подтверждён: закрытый ключ сертификата не обнаружен")
return row
}
export function assertLogicalCertificateName(value: unknown, name: string): void {
if (rows(value).filter(row => row.name === name).length > 1) throw new Error("Имя существующего сертификата неоднозначно; сначала устраните дубликаты")
}
export async function applyVerifiedCertificateToServices(deps: {
certificates: () => Promise<unknown>
@@ -31,3 +35,35 @@ export async function applyVerifiedCertificateToServices(deps: {
if (matching.length !== 1 || matching[0]!.certificate !== name || !["false", "no"].includes(String(matching[0]!.disabled))) throw new Error(`Назначение сертификата сервису ${service} не подтверждено; проверьте фактические настройки устройства`)
}
}
export async function rotateCertificateGeneration(deps: {
certificates: () => Promise<unknown>
apply: (name: string) => Promise<void>
services: () => Promise<unknown>
rename: (id: string, name: string) => Promise<void>
}, logicalName: string, generationName: string, archiveName: string, expectedFingerprint: string): Promise<void> {
if (new Set([logicalName,generationName,archiveName]).size!==3) throw new Error("Имена поколений сертификата должны различаться")
const initial=await deps.certificates()
assertLogicalCertificateName(initial,logicalName)
const candidate=assertImportedCertificate(initial,generationName,expectedFingerprint)
const previous=rows(initial).find(row=>row.name===logicalName)
if(rows(initial).some(row=>row.name===archiveName))throw new Error("Имя архива сертификата уже занято")
const id=(row:Row):string=>{if(typeof row[".id"]!=="string"||!/^\*[a-z0-9]+$/i.test(row[".id"]))throw new Error("Native ID сертификата не подтверждён");return row[".id"]}
const candidateId=id(candidate),previousId=previous?id(previous):null
if(candidateId===previousId)throw new Error("Native ID поколений сертификата неоднозначен")
const verified=(name:string)=>applyVerifiedCertificateToServices({certificates:deps.certificates,apply:()=>deps.apply(name),services:deps.services},name,expectedFingerprint)
// Keep the original identity and key intact until both services use the verified generation.
await verified(generationName)
const current=await deps.certificates()
assertLogicalCertificateName(current,logicalName)
const currentPrevious=rows(current).find(row=>row.name===logicalName)
if(previousId ? !currentPrevious||id(currentPrevious)!==previousId||currentPrevious.fingerprint!==previous!.fingerprint : !!currentPrevious)throw new Error("Исходный сертификат изменился во время переключения; проверьте фактические назначения")
if(previousId)await deps.rename(previousId,archiveName)
const staged=await deps.certificates()
if(rows(staged).some(row=>row.name===logicalName))throw new Error("Освобождение исходного имени сертификата не подтверждено")
if(id(assertImportedCertificate(staged,generationName,expectedFingerprint))!==candidateId)throw new Error("Native ID нового поколения изменился")
if(previousId&&!rows(staged).some(row=>row.name===archiveName&&row[".id"]===previousId&&row.fingerprint===previous!.fingerprint))throw new Error("Сохранение прежнего поколения не подтверждено")
await deps.rename(candidateId,logicalName)
await verified(logicalName)
// The archived generation is intentionally retained; other consumers may still need its key.
}