sipher/tests/key.test.ts
Nixyan 66ebebd105 refactor: modularize plugins with federation and encryption infrastructure
Major changes:
- Restructure plugin architecture: moved federation logic into a dedicated `federation` plugin with Better Auth integration, defining schemas for server registry, key rotation, and blacklist management
- Extract encryption layer: new `oven` plugin handles end-to-end encryption (E2EE) with OLM client/server implementations
- Reorganize social features: consolidated social endpoints (posts, follows, blocks, mutes) and removed legacy plugin patterns in favor of unified plugin structure
- Decentralized key management: refactored `keytools` and `keygen` to support federation key rotation with challenge tokens and health checks

Infrastructure updates:
- Upgrade dependencies: bump Better Auth to 1.6.9, React to 19.2.5, Next.js to 16.2.3, Tailwind to 4.2.4
- Add cryptographic libraries: @scure/bip39, @signalapp/libsignal-client, @matrix-org/matrix-sdk-crypto-wasm for enhanced federation security
- Add utilities: base58-js, uuid for federation identifier handling
- Update database schema with new federation tables (serverRegistry, rotateChallengeTokens, blacklistedServers)

Minor updates: test suite alignment, storage client cleanup, PostFederationSchema refinements

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 11:40:14 -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 type { EncryptedEnvelope } from "@/lib/federation/keytools"
import { decryptPayload, encryptPayload, signMessage } from "@/lib/federation/keytools"
import { expect, test } from "@playwright/test"
import createDebug from "debug"
import { clearTables, generateEnvKeyPair, 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 generateEnvKeyPair>,
newKeys: ReturnType<typeof generateEnvKeyPair>,
) {
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 = generateEnvKeyPair()
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 = generateEnvKeyPair()
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 = generateEnvKeyPair()
const newKeys = generateEnvKeyPair()
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 = generateEnvKeyPair()
const newKeys1 = generateEnvKeyPair()
const newKeys2 = generateEnvKeyPair()
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 = generateEnvKeyPair()
const newKeys = generateEnvKeyPair()
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 = generateEnvKeyPair()
const newKeys = generateEnvKeyPair()
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 = generateEnvKeyPair()
const newKeys = generateEnvKeyPair()
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)
})