- Added new environment variables for MinIO configuration in .env.local.example. - Updated package.json and bun.lock to include new dependencies for key management and encryption. - Refactored server and route handling to support Ed25519 and X25519 key pairs for improved security during key rotation. - Implemented validation for public keys and enhanced error handling in the discovery routes. - Introduced new challenges for key rotation, ensuring secure communication between federations. - Updated README with additional instructions for the new key rotation process.
172 lines
7.6 KiB
TypeScript
172 lines
7.6 KiB
TypeScript
import db from "@/lib/db";
|
||
import { blacklistedServers, rotateChallengeTokens, serverRegistry } from "@/lib/db/schema";
|
||
import { decryptPayload, verifySignature } from "@/lib/federation/keytools";
|
||
import createDebug from "debug";
|
||
import { eq, sql } from "drizzle-orm";
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { z } from "zod";
|
||
|
||
const debug = createDebug("app:discover:rotate:confirm");
|
||
|
||
/**
|
||
* Confirms a key rotation challenge issued by /discover/rotate/init.
|
||
*
|
||
* Terminology: SA = this server (Server A), SB = the server rotating its keys (Server B).
|
||
*
|
||
* Full rotation flow:
|
||
* 1. SB generates new Ed25519 + X25519 keypairs.
|
||
* 2. SB sends { url, newSigningPublicKey, newEncryptionPublicKey } to SA's /discover/rotate/init.
|
||
* 3. SA issues 4 challenges:
|
||
* - signingOldChallenge: plaintext nonce (SB signs with old Ed25519 key)
|
||
* - signingNewChallenge: plaintext nonce (SB signs with new Ed25519 key)
|
||
* - encryptionOldChallenge: nonce encrypted with SB's current X25519 key
|
||
* - encryptionNewChallenge: nonce encrypted with SB's new X25519 key
|
||
* 4. SB solves all 4 challenges:
|
||
* - Signs the signing challenges with respective Ed25519 keys
|
||
* - Decrypts the encryption challenges with respective X25519 keys
|
||
* 5. SB fetches SA's /discover to get SA's X25519 public key, then encrypts
|
||
* all 4 proof values into a single EncryptedEnvelope using SA's X25519 key.
|
||
* 6. SA decrypts the envelope and verifies all 4 proofs.
|
||
*
|
||
* What each check proves:
|
||
* - signingOldSignature: SB holds the old Ed25519 private key (identity proof)
|
||
* - signingNewSignature: SB holds the new Ed25519 private key (ownership proof)
|
||
* - encryptionOldPlaintext: SB holds the old X25519 private key (encryption identity proof)
|
||
* - encryptionNewPlaintext: SB holds the new X25519 private key (encryption ownership proof)
|
||
* - Envelope encrypted with SA's X25519 key: SB fetched SA's /discover (identity binding)
|
||
* - Discover being fetched: SB fetched SA's /discover endpoint (liveliness proof) <- Not accounted for but it is a proof that the other federation is alive and responsive.
|
||
*/
|
||
export async function POST(request: NextRequest) {
|
||
const body = await request.json();
|
||
debug("POST /discover/rotate/confirm – confirmation request for %s", body?.serverUrl);
|
||
|
||
const validated = z.object({
|
||
serverUrl: z.url(),
|
||
envelope: z.object({
|
||
ephemeralPublicKey: z.string(),
|
||
iv: z.string(),
|
||
ciphertext: z.string(),
|
||
authTag: z.string(),
|
||
}),
|
||
}).safeParse(body);
|
||
|
||
if (!validated.success) {
|
||
debug("POST /discover/rotate/confirm – validation failed: %o", validated.error.message);
|
||
return NextResponse.json({ error: validated.error.message }, { status: 400 });
|
||
}
|
||
|
||
const [blacklisted] = await db.select().from(blacklistedServers)
|
||
.where(eq(blacklistedServers.serverUrl, validated.data.serverUrl));
|
||
if (blacklisted) {
|
||
debug("POST /discover/rotate/confirm – server %s is blacklisted", validated.data.serverUrl);
|
||
return NextResponse.json({ error: "Your server has been blacklisted. Please contact support to unblacklist your server." }, { status: 403 });
|
||
}
|
||
|
||
debug("POST /discover/rotate/confirm – fetching pending challenge for %s", validated.data.serverUrl);
|
||
|
||
return await db.transaction(async (tx) => {
|
||
const [challenge] = await tx.select().from(rotateChallengeTokens)
|
||
.where(eq(rotateChallengeTokens.serverUrl, validated.data.serverUrl))
|
||
.for("update");
|
||
|
||
if (!challenge) {
|
||
debug("POST /discover/rotate/confirm – no pending challenge found");
|
||
return NextResponse.json({ error: "No pending rotation challenge found for this server." }, { status: 404 });
|
||
}
|
||
|
||
if (challenge.expiresAt < new Date()) {
|
||
debug("POST /discover/rotate/confirm – challenge expired at %s", challenge.expiresAt.toISOString());
|
||
await tx.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, challenge.id));
|
||
return NextResponse.json({ error: "Challenge token has expired." }, { status: 400 });
|
||
}
|
||
|
||
if (challenge.attemptsLeft <= 0) {
|
||
debug("POST /discover/rotate/confirm – no attempts left, blacklisting %s", challenge.serverUrl);
|
||
await tx.insert(blacklistedServers).values({
|
||
id: crypto.randomUUID(),
|
||
serverUrl: challenge.serverUrl,
|
||
reason: "Too many failed attempts to confirm key rotation challenge",
|
||
createdAt: new Date(),
|
||
});
|
||
await tx.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, challenge.id));
|
||
return NextResponse.json({ error: "Your server has been blacklisted. Please contact support to unblacklist your server." }, { status: 403 });
|
||
}
|
||
|
||
debug("POST /discover/rotate/confirm – %d attempt(s) left, decrypting envelope", challenge.attemptsLeft);
|
||
|
||
const ownEncryptionSecretKey = new Uint8Array(
|
||
Buffer.from(process.env.FEDERATION_ENCRYPTION_PRIVATE_KEY!, "base64"),
|
||
);
|
||
|
||
let proofs: {
|
||
signingOldSignature: string;
|
||
signingNewSignature: string;
|
||
encryptionOldPlaintext: string;
|
||
encryptionNewPlaintext: string;
|
||
};
|
||
try {
|
||
const decrypted = decryptPayload(validated.data.envelope, ownEncryptionSecretKey);
|
||
proofs = JSON.parse(decrypted);
|
||
} catch {
|
||
debug("POST /discover/rotate/confirm – envelope decryption failed, decrementing attempts");
|
||
await tx.update(rotateChallengeTokens).set({
|
||
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
|
||
}).where(eq(rotateChallengeTokens.id, challenge.id));
|
||
return NextResponse.json({
|
||
error: `Failed to decrypt envelope. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
|
||
}, { status: 400 });
|
||
}
|
||
|
||
const [server] = await tx.select().from(serverRegistry)
|
||
.where(eq(serverRegistry.url, challenge.serverUrl));
|
||
|
||
if (!server) {
|
||
debug("POST /discover/rotate/confirm – server not found in registry");
|
||
return NextResponse.json({ error: "Server not found in registry." }, { status: 404 });
|
||
}
|
||
|
||
const currentSigningPub = new Uint8Array(Buffer.from(server.publicKey, "base64"));
|
||
const newSigningPub = new Uint8Array(Buffer.from(challenge.newSigningPublicKey, "base64"));
|
||
|
||
const signingOldValid = verifySignature(
|
||
challenge.signingOldToken,
|
||
proofs.signingOldSignature,
|
||
currentSigningPub,
|
||
);
|
||
const signingNewValid = verifySignature(
|
||
challenge.signingNewToken,
|
||
proofs.signingNewSignature,
|
||
newSigningPub,
|
||
);
|
||
const encOldValid = proofs.encryptionOldPlaintext === challenge.encryptionOldToken;
|
||
const encNewValid = proofs.encryptionNewPlaintext === challenge.encryptionNewToken;
|
||
|
||
if (!signingOldValid || !signingNewValid || !encOldValid || !encNewValid) {
|
||
debug(
|
||
"POST /discover/rotate/confirm – proof mismatch (sigOld=%s, sigNew=%s, encOld=%s, encNew=%s), decrementing",
|
||
signingOldValid ? "ok" : "FAIL",
|
||
signingNewValid ? "ok" : "FAIL",
|
||
encOldValid ? "ok" : "FAIL",
|
||
encNewValid ? "ok" : "FAIL",
|
||
);
|
||
await tx.update(rotateChallengeTokens).set({
|
||
attemptsLeft: sql`${rotateChallengeTokens.attemptsLeft} - 1`,
|
||
}).where(eq(rotateChallengeTokens.id, challenge.id));
|
||
return NextResponse.json({
|
||
error: `Challenge verification failed. You have ${challenge.attemptsLeft - 1} attempts left before your server is blacklisted.`,
|
||
}, { status: 400 });
|
||
}
|
||
|
||
debug("POST /discover/rotate/confirm – all 4 proofs passed, rotating keys for %s", challenge.serverUrl);
|
||
await tx.update(serverRegistry).set({
|
||
publicKey: challenge.newSigningPublicKey,
|
||
encryptionPublicKey: challenge.newEncryptionPublicKey,
|
||
updatedAt: new Date(),
|
||
}).where(eq(serverRegistry.url, challenge.serverUrl));
|
||
|
||
await tx.delete(rotateChallengeTokens).where(eq(rotateChallengeTokens.id, challenge.id));
|
||
|
||
debug("POST /discover/rotate/confirm – key rotation complete for %s", challenge.serverUrl);
|
||
return NextResponse.json({ message: "Key rotation confirmed successfully." });
|
||
});
|
||
}
|