fix(certificates): заменять сертификат уникальным поколением
This commit is contained in:
1 parent
c683e1b2c1
commit
8c5cfa0706
11 files changed
+220
-19
No files matched your search
@@ -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.
|
||||
}
|
||||
Reference in new issue
Block a user