sipher/tests/key.test.ts
Nixyan c587737f38 feat: enhance federation key rotation and server discovery functionality
- 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.
2026-03-12 18:42:52 -03:00

279 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Tests the key rotation flow.
*
* This test covers:
* - Init endpoint: validation, not-found, duplicate challenge
* - Missing challenge on confirm
* - Expired challenge on confirm
* - Wrong challenge proofs (full init → confirm flow)
* - Blacklists server after too many failed attempts
* - Full init → confirm happy path that rotates both keys
*/
import { expect, test } from "@playwright/test"
import createDebug from "debug"
import type { EncryptedEnvelope } from "@/lib/federation/keytools"
import { decryptPayload, encryptPayload, signMessage } from "@/lib/federation/keytools"
import { clearTables, generateKeypair, getServerByUrl, seedChallenge, seedServer } from "./helpers/db"
const debug = createDebug("test:key")
const SERVER_URL = "https://test-server.com"
test.beforeEach(async ({ }, testInfo) => {
debug("beforeEach clearing tables for: %s", testInfo.title)
await clearTables()
})
test.afterEach(async ({ }, testInfo) => {
debug("afterEach clearing tables after: %s", testInfo.title)
await clearTables()
})
function getOwnEncryptionPublicKey(): Uint8Array {
return new Uint8Array(Buffer.from(process.env.FEDERATION_ENCRYPTION_PUBLIC_KEY!, "base64"))
}
function buildBadEnvelope() {
return encryptPayload(
JSON.stringify({
signingOldSignature: "wrong",
signingNewSignature: "wrong",
encryptionOldPlaintext: "wrong",
encryptionNewPlaintext: "wrong",
}),
getOwnEncryptionPublicKey(),
)
}
interface InitChallenges {
signingOldChallenge: string
signingNewChallenge: string
encryptionOldChallenge: EncryptedEnvelope
encryptionNewChallenge: EncryptedEnvelope
}
function solveInitChallenges(
challenges: InitChallenges,
oldKeys: ReturnType<typeof generateKeypair>,
newKeys: ReturnType<typeof generateKeypair>,
) {
const oldSigningSecret = new Uint8Array(Buffer.from(oldKeys.signingSecretKey, "base64"))
const newSigningSecret = new Uint8Array(Buffer.from(newKeys.signingSecretKey, "base64"))
const oldEncSecret = new Uint8Array(Buffer.from(oldKeys.encryptionSecretKey, "base64"))
const newEncSecret = new Uint8Array(Buffer.from(newKeys.encryptionSecretKey, "base64"))
return {
signingOldSignature: signMessage(challenges.signingOldChallenge, oldSigningSecret),
signingNewSignature: signMessage(challenges.signingNewChallenge, newSigningSecret),
encryptionOldPlaintext: decryptPayload(challenges.encryptionOldChallenge, oldEncSecret),
encryptionNewPlaintext: decryptPayload(challenges.encryptionNewChallenge, newEncSecret),
}
}
// ---------------------------------------------------------------------------
// rotate/init tests
// ---------------------------------------------------------------------------
test("init rejects unregistered server", async ({ request }) => {
const newKeys = generateKeypair()
const res = await request.post("/discover/rotate/init", {
data: {
url: "https://unknown-server.com",
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
expect(res.status()).toBe(404)
})
test("init rejects same keys as currently registered", async ({ request }) => {
const keys = generateKeypair()
await seedServer(SERVER_URL, keys.signingPublicKey, keys.encryptionPublicKey)
const res = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: keys.signingPublicKey,
newEncryptionPublicKey: keys.encryptionPublicKey,
}
})
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /already registered/i })
})
test("init issues 4 challenges", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
const res = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
expect(res.status()).toBe(200)
const body = await res.json()
expect(body.signingOldChallenge).toBeDefined()
expect(body.signingNewChallenge).toBeDefined()
expect(body.encryptionOldChallenge).toBeDefined()
expect(body.encryptionOldChallenge.ephemeralPublicKey).toBeDefined()
expect(body.encryptionNewChallenge).toBeDefined()
expect(body.encryptionNewChallenge.ephemeralPublicKey).toBeDefined()
})
test("init rejects duplicate while challenge is pending", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys1 = generateKeypair()
const newKeys2 = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
const res1 = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys1.signingPublicKey,
newEncryptionPublicKey: newKeys1.encryptionPublicKey,
}
})
expect(res1.status()).toBe(200)
const res2 = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys2.signingPublicKey,
newEncryptionPublicKey: newKeys2.encryptionPublicKey,
}
})
expect(res2.status()).toBe(409)
expect(await res2.json()).toMatchObject({ error: /already pending/i })
})
// ---------------------------------------------------------------------------
// rotate/confirm tests
// ---------------------------------------------------------------------------
test("confirm rejects missing challenge", async ({ request }) => {
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: "https://ghost-server.com",
envelope: buildBadEnvelope(),
}
})
expect(res.status()).toBe(404)
})
test("confirm rejects expired challenge", async ({ request }) => {
await seedChallenge({ expiresAt: new Date(Date.now() - 1000) })
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
}
})
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /expired/ })
})
test("confirm rejects wrong proofs (init → confirm)", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
debug("test: wrong proofs calling init")
const initRes = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
expect(initRes.status()).toBe(200)
debug("test: wrong proofs confirming with garbage proofs")
const confirmRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
}
})
expect(confirmRes.status()).toBe(400)
expect(await confirmRes.json()).toMatchObject({ error: /failed/i })
})
test("confirm blacklists after too many failed attempts", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
debug("test: blacklists calling init")
const initRes = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
expect(initRes.status()).toBe(200)
for (let i = 0; i < 3; i++) {
debug("test: blacklists wrong attempt %d/3", i + 1)
const res = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
}
})
expect(res.status()).toBe(400)
expect(await res.json()).toMatchObject({ error: /failed/i })
}
debug("test: blacklists 4th attempt triggers blacklist")
const finalRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope: buildBadEnvelope(),
}
})
expect(finalRes.status()).toBe(403)
expect(await finalRes.json()).toMatchObject({ error: /blacklisted/ })
})
// ---------------------------------------------------------------------------
// Full init → confirm happy path
// ---------------------------------------------------------------------------
test("full rotation flow: init → solve → confirm rotates both keys", async ({ request }) => {
const oldKeys = generateKeypair()
const newKeys = generateKeypair()
await seedServer(SERVER_URL, oldKeys.signingPublicKey, oldKeys.encryptionPublicKey)
debug("test: full flow calling init")
const initRes = await request.post("/discover/rotate/init", {
data: {
url: SERVER_URL,
newSigningPublicKey: newKeys.signingPublicKey,
newEncryptionPublicKey: newKeys.encryptionPublicKey,
}
})
expect(initRes.status()).toBe(200)
const challenges: InitChallenges = await initRes.json()
debug("test: full flow solving challenges")
const proofs = solveInitChallenges(challenges, oldKeys, newKeys)
debug("test: full flow building proof envelope encrypted with SA's X25519 key")
const envelope = encryptPayload(JSON.stringify(proofs), getOwnEncryptionPublicKey())
debug("test: full flow confirming")
const confirmRes = await request.post("/discover/rotate/confirm", {
data: {
serverUrl: SERVER_URL,
envelope,
}
})
expect(confirmRes.status()).toBe(200)
expect(await confirmRes.json()).toMatchObject({ message: /confirmed/ })
debug("test: full flow verifying keys were rotated in DB")
const server = await getServerByUrl(SERVER_URL)
expect(server).toBeDefined()
expect(server!.publicKey).toBe(newKeys.signingPublicKey)
expect(server!.encryptionPublicKey).toBe(newKeys.encryptionPublicKey)
})