"use strict"; const crypto = require("node:crypto"); function stable(value) { if (Array.isArray(value)) return value.map(stable); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])); return value; } function planId(value) { return crypto.createHash("sha256").update(JSON.stringify(stable(value))).digest("hex"); } function keyMaterial(value) { return String(value || "").trim().split(/\s+/).slice(0, 2).join(" "); } class DeployKeyLifecycleService { constructor({ store, gitea, keyHost, audit = null, clock = () => new Date().toISOString() }) { this.store = store; this.gitea = gitea; this.keyHost = keyHost; this.audit = audit; this.clock = clock; } coordinates(repository) { const [owner, repo] = String(repository?.fullName || "").split("/"); if (!owner || !repo) throw Object.assign(new Error("A full Gitea repository name is required."), { code: "DEPLOY_KEY_REPOSITORY_REQUIRED" }); return { owner, repo }; } profile(repository, profileId) { const profile = this.store.getDeploymentProfile(repository.fullName, profileId); if (!profile) throw Object.assign(new Error("Deployment profile not found."), { code: "DEPLOY_KEY_PROFILE_NOT_FOUND" }); const server = this.store.getServer(profile.serverId); if (!server) throw Object.assign(new Error("Deployment server not found."), { code: "DEPLOY_KEY_SERVER_NOT_FOUND" }); return { profile, server }; } configuredReferences() { const references = []; const configured = this.store.data?.deploymentProfiles ? Object.entries(this.store.data.deploymentProfiles).map(([fullName, profiles]) => ({ fullName, profiles })) : (this.store.getRepositories?.() || []).map((repository) => ({ fullName: repository.fullName, profiles: this.store.getDeploymentProfiles(repository.fullName) || [] })); for (const repository of configured) { for (const profile of repository.profiles || []) { if (!profile.serverGitAccess?.deployKeyId && !profile.serverGitAccess?.keyFingerprint) continue; references.push({ repository: repository.fullName, profileId: profile.id, serverId: profile.serverId, keyId: profile.serverGitAccess.deployKeyId || null, fingerprint: profile.serverGitAccess.keyFingerprint || null }); } } return references; } async inventory({ repository, profileId }) { const { profile, server } = this.profile(repository, profileId); const { owner, repo } = this.coordinates(repository); const [remoteKeys, serverKey] = await Promise.all([ this.gitea.listDeployKeys(owner, repo), this.keyHost.inspect({ repository, profile, server }), ]); const configuredId = Number(profile.serverGitAccess?.deployKeyId) || null; const configured = remoteKeys.find((key) => Number(key.id) === configuredId) || null; const material = keyMaterial(serverKey?.publicKey); const matching = material ? remoteKeys.filter((key) => keyMaterial(key.key) === material) : []; const references = this.configuredReferences(); const shared = references.filter((reference) => reference.fingerprint && reference.fingerprint === serverKey?.fingerprint && (reference.repository !== repository.fullName || reference.profileId !== profileId)); const conflicts = remoteKeys.filter((key) => key.read_only !== true && (!configuredId || Number(key.id) === configuredId || keyMaterial(key.key) === material)); const stale = Boolean(configuredId && !configured) || Boolean(profile.serverGitAccess?.keyFingerprint && serverKey?.fingerprint && profile.serverGitAccess.keyFingerprint !== serverKey.fingerprint); const orphaned = remoteKeys.filter((key) => /ForgeFlow/i.test(String(key.title || "")) && !references.some((reference) => Number(reference.keyId) === Number(key.id))); return { repository: repository.fullName, profileId, server: { id: server.id, name: server.name }, configuredKey: configured ? { id: configured.id, title: configured.title, readOnly: configured.read_only === true, key: configured.key || null } : null, serverKey, matchingKeys: matching.map((key) => ({ id: key.id, readOnly: key.read_only === true })), stale, orphaned: orphaned.map((key) => ({ id: key.id, title: key.title })), shared, conflicts: conflicts.map((key) => ({ id: key.id, title: key.title, readOnly: false })), ready: Boolean(configured && configured.read_only === true && serverKey?.privateKeyPresent && serverKey?.fingerprint === profile.serverGitAccess?.keyFingerprint && !shared.length && !conflicts.length), checkedAt: this.clock(), }; } async planRotation({ repository, profileId }) { const evidence = await this.inventory({ repository, profileId }); const plan = { operation: "rotate-deploy-key", repository: repository.fullName, profileId, currentKeyId: evidence.configuredKey?.id || null, currentFingerprint: evidence.serverKey?.fingerprint || null, serverId: evidence.server.id, impact: ["Generate a new private key on the linked server", "Register only its public key in this repository", "Verify read-only branch access", "Switch the profile atomically", "Revoke the previous key after the switch"], recovery: "The previous server key and profile metadata remain recoverable until post-rotation verification succeeds.", evidence, }; plan.id = planId(plan); await this.audit?.append?.("deployment.deploy-key-rotation-planned", { repository: repository.fullName, profileId, planId: plan.id }); return plan; } async rotate({ repository, profileId, expectedPlanId }) { const plan = await this.planRotation({ repository, profileId }); if (!expectedPlanId) throw Object.assign(new Error("Review a deploy-key rotation plan before applying it."), { code: "DEPLOY_KEY_ROTATION_PLAN_REQUIRED", plan }); if (expectedPlanId !== plan.id) throw Object.assign(new Error("Deploy-key evidence changed after preview. Review a fresh plan."), { code: "DEPLOY_KEY_ROTATION_PLAN_STALE", plan }); const { profile, server } = this.profile(repository, profileId); const { owner, repo } = this.coordinates(repository); const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-rotation:${repository.fullName}:${profileId}`); const previous = { profile: structuredClone(profile), key: await this.keyHost.backup({ repository, profile, server }), remoteKey: plan.evidence.configuredKey }; let candidate = null; let registered = null; let switched = false; let oldRevoked = false; try { candidate = await this.keyHost.generate({ repository, profile, server }); if (!candidate?.publicKey || !candidate?.fingerprint || candidate.privateKey) throw Object.assign(new Error("The server did not return safe public-key evidence."), { code: "DEPLOY_KEY_CANDIDATE_INVALID" }); registered = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · ${candidate.fingerprint.slice(-12)}`, publicKey: candidate.publicKey }); if (registered.read_only !== true) throw Object.assign(new Error("Gitea registered the candidate with write access."), { code: "DEPLOY_KEY_NOT_READ_ONLY" }); const proof = await this.keyHost.verifyCandidate({ repository, profile, server, candidate, keyId: registered.id }); if (!proof?.ready || proof.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("The candidate deploy key could not prove read-only repository access."), { code: "DEPLOY_KEY_CANDIDATE_VERIFICATION_FAILED", proof }); await this.keyHost.preflightCandidate({ repository, profile, server, candidate, proof }); await this.keyHost.promote({ repository, profile, server, candidate, previous }); const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { configured: true, deployKeyId: registered.id, keyFingerprint: candidate.fingerprint, hostFingerprint: proof.hostFingerprint, configuredAt: this.clock(), rotatedAt: this.clock(), previousKeyId: previous.remoteKey?.id || null } }); switched = true; if (previous.remoteKey?.id) { await this.gitea.deleteDeployKey(owner, repo, previous.remoteKey.id); oldRevoked = true; } const post = await this.keyHost.verifyActive({ repository, profile: updated, server }); if (!post?.ready || post.fingerprint !== candidate.fingerprint) throw Object.assign(new Error("Post-rotation verification failed."), { code: "DEPLOY_KEY_POST_ROTATION_FAILED", post }); await this.keyHost.commit({ repository, profile: updated, server, candidate, previous }); await this.audit?.append?.("deployment.deploy-key-rotated", { repository: repository.fullName, profileId, oldKeyId: previous.remoteKey?.id || null, newKeyId: registered.id, fingerprint: candidate.fingerprint, snapshot: snapshot?.filePath || null }); return { profile: updated, proof: post, snapshot, recovery: previous.key?.recovery || null }; } catch (error) { try { if (candidate) await this.keyHost.rollback({ repository, profile, server, candidate, previous }); if (registered?.id) await this.gitea.deleteDeployKey(owner, repo, registered.id).catch(() => {}); let restoredKey = null; if (oldRevoked && previous.remoteKey?.key) restoredKey = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: previous.remoteKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: previous.remoteKey.key }); if (switched) await this.store.saveDeploymentProfile(repository.fullName, restoredKey ? { ...previous.profile, serverGitAccess: { ...previous.profile.serverGitAccess, deployKeyId: restoredKey.id } } : previous.profile); } catch (rollbackError) { error.rollbackError = rollbackError.message; } await this.audit?.append?.("deployment.deploy-key-rotation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_ROTATION_FAILED", rollbackError: error.rollbackError || null }); throw error; } } async planRevocation({ repository, profileId }) { const evidence = await this.inventory({ repository, profileId }); const plan = { operation: "revoke-deploy-key", repository: repository.fullName, profileId, keyId: evidence.configuredKey?.id || null, fingerprint: evidence.serverKey?.fingerprint || null, linkedDeployments: [profileId], impact: ["Remove this repository deploy key from Gitea", "Disable server-pull deployment until restored", "Preserve server-side recovery material"], containersUnaffected: true, evidence }; plan.id = planId(plan); return plan; } async revoke({ repository, profileId, expectedPlanId }) { const plan = await this.planRevocation({ repository, profileId }); if (!expectedPlanId) throw Object.assign(new Error("Review revocation impact before applying it."), { code: "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED", plan }); if (plan.id !== expectedPlanId) throw Object.assign(new Error("Deploy-key evidence changed after preview."), { code: "DEPLOY_KEY_REVOCATION_PLAN_STALE", plan }); const { profile, server } = this.profile(repository, profileId); const { owner, repo } = this.coordinates(repository); const snapshot = await this.store.createRecoverySnapshot?.(`deploy-key-revocation:${repository.fullName}:${profileId}`); const recovery = await this.keyHost.backup({ repository, profile, server }); let remoteDeleted = false; try { if (plan.keyId) { await this.gitea.deleteDeployKey(owner, repo, plan.keyId); remoteDeleted = true; } await this.keyHost.revoke({ repository, profile, server, recovery }); const updated = await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, configured: false, revokedAt: this.clock(), recoveryAvailable: true }, deploymentMode: "monitor-only" }); await this.audit?.append?.("deployment.deploy-key-revoked", { repository: repository.fullName, profileId, keyId: plan.keyId, snapshot: snapshot?.filePath || null }); return { profile: updated, snapshot, recovery: recovery?.recovery || null }; } catch (error) { if (remoteDeleted && plan.evidence.configuredKey?.key) { const restored = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: plan.evidence.configuredKey.title || `ForgeFlow · ${server.name} · restored`, publicKey: plan.evidence.configuredKey.key }); await this.store.saveDeploymentProfile(repository.fullName, { ...profile, serverGitAccess: { ...profile.serverGitAccess, deployKeyId: restored.id } }); } await this.keyHost.restore({ repository, profile, server }).catch(() => {}); await this.audit?.append?.("deployment.deploy-key-revocation-failed", { repository: repository.fullName, profileId, code: error.code || "DEPLOY_KEY_REVOCATION_FAILED" }); throw error; } } async restore({ repository, profileId }) { const { profile, server } = this.profile(repository, profileId); const restored = await this.keyHost.restore({ repository, profile, server }); if (!restored?.publicKey || !restored?.fingerprint) throw Object.assign(new Error("No valid deploy-key recovery material exists."), { code: "DEPLOY_KEY_RECOVERY_UNAVAILABLE" }); const { owner, repo } = this.coordinates(repository); const key = await this.gitea.createReadOnlyDeployKey({ owner, repo, title: `ForgeFlow · ${server.name} · restored`, publicKey: restored.publicKey }); if (key.read_only !== true) throw Object.assign(new Error("The restored key is not read-only."), { code: "DEPLOY_KEY_NOT_READ_ONLY" }); const proposed = { ...profile, deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: key.id, keyFingerprint: restored.fingerprint, hostFingerprint: restored.hostFingerprint, restoredAt: this.clock() } }; const proof = await this.keyHost.verifyActive({ repository, profile: proposed, server }); if (!proof?.ready) throw Object.assign(new Error("Restored access could not be verified."), { code: "DEPLOY_KEY_RECOVERY_VERIFICATION_FAILED" }); const updated = await this.store.saveDeploymentProfile(repository.fullName, proposed); await this.audit?.append?.("deployment.deploy-key-restored", { repository: repository.fullName, profileId, keyId: key.id, fingerprint: restored.fingerprint }); return { profile: updated, proof }; } } module.exports = { DeployKeyLifecycleService, keyMaterial, deployKeyPlanId: planId };