diff --git a/backend/oko_api/server/src/commit_reveal/allowed_apis.ts b/backend/oko_api/server/src/commit_reveal/allowed_apis.ts new file mode 100644 index 000000000..96eb2f096 --- /dev/null +++ b/backend/oko_api/server/src/commit_reveal/allowed_apis.ts @@ -0,0 +1,32 @@ +import type { + OperationType, + ApiName, +} from "@oko-wallet/oko-types/commit_reveal"; + +export const ALLOWED_APIS = { + sign_in: ["signin"], + sign_up: ["keygen"], + sign_in_reshare: ["signin", "reshare"], + add_ed25519: ["keygen_ed25519"], +}; + +export const FINAL_APIS = { + sign_in: ["signin"], + sign_up: ["keygen"], + sign_in_reshare: ["reshare"], + add_ed25519: ["keygen_ed25519"], +}; + +export function isApiAllowed( + operationType: OperationType, + apiName: ApiName, +): boolean { + return ALLOWED_APIS[operationType].includes(apiName); +} + +export function isFinalApi( + operationType: OperationType, + apiName: ApiName, +): boolean { + return FINAL_APIS[operationType].includes(apiName); +} diff --git a/backend/oko_api/server/src/commit_reveal/index.ts b/backend/oko_api/server/src/commit_reveal/index.ts new file mode 100644 index 000000000..3a4a60bc9 --- /dev/null +++ b/backend/oko_api/server/src/commit_reveal/index.ts @@ -0,0 +1 @@ +export * from "./allowed_apis"; \ No newline at end of file diff --git a/backend/oko_api/server/src/middleware/commit_reveal.test.ts b/backend/oko_api/server/src/middleware/commit_reveal.test.ts new file mode 100644 index 000000000..fe900c95c --- /dev/null +++ b/backend/oko_api/server/src/middleware/commit_reveal.test.ts @@ -0,0 +1,1169 @@ +import request from "supertest"; +import express from "express"; +import type { Pool } from "pg"; +import { Bytes } from "@oko-wallet/bytes"; +import { randomBytes } from "node:crypto"; +import { v4 as uuidv4 } from "uuid"; +import { createPgConn } from "@oko-wallet/postgres-lib"; +import winston from "winston"; +import { sha256 } from "@oko-wallet/crypto-js"; +import { + generateEddsaKeypair, + signMessage, + convertEddsaSignatureToBytes, +} from "@oko-wallet/crypto-js/node/ecdhe"; + +import { testPgConfig } from "@oko-wallet-api/database/test_config"; +import { resetPgDatabase } from "@oko-wallet-api/testing/database"; +import { commitRevealMiddleware } from "./commit_reveal"; + +// Mock keypair for testing +const privateKeyRes = Bytes.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000001", + 32, +); +const publicKeyRes = Bytes.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000002", + 32, +); +if (!privateKeyRes.success || !publicKeyRes.success) { + throw new Error("Failed to create mock keypair"); +} +const mockServerKeypair = { + privateKey: privateKeyRes.data, + publicKey: publicKeyRes.data, +}; + +const testLogger = winston.createLogger({ + level: "error", + silent: true, + transports: [new winston.transports.Console()], +}); + +function generateRandomHex(bytes: number): string { + return randomBytes(bytes).toString("hex"); +} + +describe("commit_reveal_middleware_test", () => { + let pool: Pool; + let app: express.Application; + + beforeAll(async () => { + const config = testPgConfig; + const createPostgresRes = await createPgConn({ + database: config.database, + host: config.host, + password: config.password, + user: config.user, + port: config.port, + ssl: config.ssl, + }); + + if (createPostgresRes.success === false) { + console.error(createPostgresRes.err); + throw new Error("Failed to create postgres database"); + } + + pool = createPostgresRes.data; + + app = express(); + app.use(express.json()); + + // Test routes with middleware + app.post( + "/test/keygen", + commitRevealMiddleware("keygen"), + (_req, res) => { + res.status(200).json({ success: true, data: { message: "keygen ok" } }); + }, + ); + app.post( + "/test/signin", + commitRevealMiddleware("signin"), + (_req, res) => { + res.status(200).json({ success: true, data: { message: "signin ok" } }); + }, + ); + + app.locals.db = pool; + app.locals.server_keypair = mockServerKeypair; + app.locals.logger = testLogger; + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + }); + + afterAll(async () => { + await pool.end(); + }); + + // Helper to create a session directly in DB + async function createSession(params: { + session_id: string; + operation_type: string; + client_ephemeral_pubkey: string; + id_token_hash: string; + state?: string; + expires_at?: Date; + }) { + const expiresAt = params.expires_at ?? new Date(Date.now() + 5 * 60 * 1000); + const state = params.state ?? "COMMITTED"; + + await pool.query( + `INSERT INTO "commit_reveal_sessions" (session_id, operation_type, client_ephemeral_pubkey, id_token_hash, state, expires_at) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + params.session_id, + params.operation_type, + Buffer.from(params.client_ephemeral_pubkey, "hex"), + params.id_token_hash, + state, + expiresAt, + ], + ); + } + + describe("missing required fields", () => { + it("should return 400 when cr_session_id is missing", async () => { + const response = await request(app) + .post("/test/keygen") + .set("Authorization", "Bearer test_token") + .send({ + cr_signature: generateRandomHex(64), + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("cr_session_id"); + }); + + it("should return 400 when cr_signature is missing", async () => { + const response = await request(app) + .post("/test/keygen") + .set("Authorization", "Bearer test_token") + .send({ + cr_session_id: uuidv4(), + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("cr_signature"); + }); + + it("should return 401 when Authorization header is missing", async () => { + const sessionId = uuidv4(); + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: generateRandomHex(32), + }); + + const response = await request(app) + .post("/test/keygen") + .send({ + cr_session_id: sessionId, + cr_signature: generateRandomHex(64), + }) + .expect(401); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("UNAUTHORIZED"); + }); + }); + + describe("session validation", () => { + it("should return 404 when session does not exist", async () => { + const response = await request(app) + .post("/test/keygen") + .set("Authorization", "Bearer test_token") + .send({ + cr_session_id: uuidv4(), + cr_signature: generateRandomHex(64), + }) + .expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("SESSION_NOT_FOUND"); + }); + + it("should return 400 when session is not in COMMITTED state", async () => { + const sessionId = uuidv4(); + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: generateRandomHex(32), + state: "COMPLETED", + }); + + const response = await request(app) + .post("/test/keygen") + .set("Authorization", "Bearer test_token") + .send({ + cr_session_id: sessionId, + cr_signature: generateRandomHex(64), + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("COMMITTED"); + }); + + it("should return 410 when session has expired", async () => { + const sessionId = uuidv4(); + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: generateRandomHex(32), + expires_at: new Date(Date.now() - 1000), // expired 1 second ago + }); + + const response = await request(app) + .post("/test/keygen") + .set("Authorization", "Bearer test_token") + .send({ + cr_session_id: sessionId, + cr_signature: generateRandomHex(64), + }) + .expect(410); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("SESSION_EXPIRED"); + }); + }); + + describe("operation-API validation", () => { + it("should return 400 when API is not allowed for operation", async () => { + const sessionId = uuidv4(); + // Create session with sign_in operation + await createSession({ + session_id: sessionId, + operation_type: "sign_in", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: generateRandomHex(32), + }); + + // Try to call keygen API (not allowed for sign_in) + const response = await request(app) + .post("/test/keygen") + .set("Authorization", "Bearer test_token") + .send({ + cr_session_id: sessionId, + cr_signature: generateRandomHex(64), + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("keygen"); + expect(response.body.msg).toContain("sign_in"); + }); + + it("should return 400 when sign_in operation tries to call keygen", async () => { + const sessionId = uuidv4(); + await createSession({ + session_id: sessionId, + operation_type: "sign_in", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: generateRandomHex(32), + }); + + const response = await request(app) + .post("/test/keygen") + .set("Authorization", "Bearer test_token") + .send({ + cr_session_id: sessionId, + cr_signature: generateRandomHex(64), + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + }); + + it("should return 400 when sign_up operation tries to call signin", async () => { + const sessionId = uuidv4(); + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: generateRandomHex(32), + }); + + const response = await request(app) + .post("/test/signin") + .set("Authorization", "Bearer test_token") + .send({ + cr_session_id: sessionId, + cr_signature: generateRandomHex(64), + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("signin"); + expect(response.body.msg).toContain("sign_up"); + }); + }); + + describe("id_token_hash validation", () => { + it("should return 400 when id_token does not match committed hash", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const originalIdToken = "original_id_token"; + const wrongIdToken = "wrong_id_token"; + + // Compute hash with original token + const hashRes = sha256(`${authType}${originalIdToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + const idTokenHash = hashRes.data.toHex(); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: idTokenHash, + }); + + const response = await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${wrongIdToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: generateRandomHex(64), + auth_type: authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("id_token_hash mismatch"); + }); + + it("should return 400 when auth_type does not match committed hash", async () => { + const sessionId = uuidv4(); + const originalAuthType = "google"; + const wrongAuthType = "auth0"; + const idToken = "test_id_token"; + + // Compute hash with original auth_type + const hashRes = sha256(`${originalAuthType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + const idTokenHash = hashRes.data.toHex(); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: idTokenHash, + }); + + // Send with wrong auth_type + const response = await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: generateRandomHex(64), + auth_type: wrongAuthType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("id_token_hash mismatch"); + }); + }); + + describe("signature validation", () => { + it("should return 400 when signature format is invalid hex", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: hashRes.data.toHex(), + }); + + const response = await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: "invalid_hex_signature_not_valid", + auth_type: authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + + it("should return 400 when signature length is wrong", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: hashRes.data.toHex(), + }); + + const response = await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: generateRandomHex(32), // 32 bytes instead of 64 + auth_type: authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + + it("should return 400 when signature verification fails (wrong message)", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + // Generate client keypair + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + // Sign wrong message + const wrongMessage = "wrong_message"; + const signRes = signMessage(wrongMessage, clientKeypair.privateKey); + if (!signRes.success) throw new Error("Failed to sign message"); + + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) throw new Error("Failed to convert signature"); + + const response = await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: sigBytesRes.data.toHex(), + auth_type: authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + + it("should return 400 when signed with different keypair", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + // Generate two different keypairs + const clientKeypairRes = generateEddsaKeypair(); + const wrongKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success || !wrongKeypairRes.success) { + throw new Error("Failed to generate keypairs"); + } + const clientKeypair = clientKeypairRes.data; + const wrongKeypair = wrongKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Create session with client keypair + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + // Create correct message but sign with wrong keypair + const nodePubkeyHex = mockServerKeypair.publicKey.toHex(); + const message = `${nodePubkeyHex}${sessionId}${authType}${idToken}sign_upkeygen`; + const signRes = signMessage(message, wrongKeypair.privateKey); + if (!signRes.success) throw new Error("Failed to sign message"); + + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) throw new Error("Failed to convert signature"); + + const response = await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: sigBytesRes.data.toHex(), + auth_type: authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + }); + + describe("auth_type handling", () => { + it("should use google as default when auth_type is not provided", async () => { + const sessionId = uuidv4(); + const idToken = "test_id_token"; + + // Compute hash with google as auth_type (default) + const hashRes = sha256(`google${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Generate client keypair + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + // Create correct message with google as auth_type + const nodePubkeyHex = mockServerKeypair.publicKey.toHex(); + const message = `${nodePubkeyHex}${sessionId}google${idToken}sign_upkeygen`; + const signRes = signMessage(message, clientKeypair.privateKey); + if (!signRes.success) throw new Error("Failed to sign message"); + + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) throw new Error("Failed to convert signature"); + + // Send without auth_type - should default to google + const response = await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: sigBytesRes.data.toHex(), + // auth_type not provided + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + }); +}); + +describe("commit_reveal_middleware_replay_and_session_test", () => { + let pool: Pool; + let app: express.Application; + + beforeAll(async () => { + const config = testPgConfig; + const createPostgresRes = await createPgConn({ + database: config.database, + host: config.host, + password: config.password, + user: config.user, + port: config.port, + ssl: config.ssl, + }); + + if (createPostgresRes.success === false) { + console.error(createPostgresRes.err); + throw new Error("Failed to create postgres database"); + } + + pool = createPostgresRes.data; + + app = express(); + app.use(express.json()); + + // Final API routes (keygen for sign_up, signin for sign_in) + app.post("/test/keygen", commitRevealMiddleware("keygen"), (_req, res) => { + res.status(200).json({ success: true, data: { message: "keygen ok" } }); + }); + app.post("/test/signin", commitRevealMiddleware("signin"), (_req, res) => { + res.status(200).json({ success: true, data: { message: "signin ok" } }); + }); + app.post("/test/reshare", commitRevealMiddleware("reshare"), (_req, res) => { + res.status(200).json({ success: true, data: { message: "reshare ok" } }); + }); + app.post("/test/keygen_ed25519", commitRevealMiddleware("keygen_ed25519"), (_req, res) => { + res.status(200).json({ success: true, data: { message: "keygen_ed25519 ok" } }); + }); + + // Route that fails + app.post("/test/keygen_fail", commitRevealMiddleware("keygen"), (_req, res) => { + res.status(500).json({ success: false, code: "INTERNAL_ERROR", msg: "Simulated failure" }); + }); + + app.locals.db = pool; + app.locals.server_keypair = mockServerKeypair; + app.locals.logger = testLogger; + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + }); + + afterAll(async () => { + await pool.end(); + }); + + async function createSession(params: { + session_id: string; + operation_type: string; + client_ephemeral_pubkey: string; + id_token_hash: string; + state?: string; + expires_at?: Date; + }) { + const expiresAt = params.expires_at ?? new Date(Date.now() + 5 * 60 * 1000); + const state = params.state ?? "COMMITTED"; + + await pool.query( + `INSERT INTO "commit_reveal_sessions" (session_id, operation_type, client_ephemeral_pubkey, id_token_hash, state, expires_at) VALUES ($1, $2, $3, $4, $5, $6)`, + [ + params.session_id, + params.operation_type, + Buffer.from(params.client_ephemeral_pubkey, "hex"), + params.id_token_hash, + state, + expiresAt, + ], + ); + } + + async function getSessionState(sessionId: string): Promise { + const result = await pool.query( + `SELECT state FROM "commit_reveal_sessions" WHERE session_id = $1`, + [sessionId], + ); + return result.rows[0]?.state ?? null; + } + + async function getApiCallCount(sessionId: string, apiName: string): Promise { + const result = await pool.query( + `SELECT COUNT(*) as count FROM "commit_reveal_api_calls" WHERE session_id = $1 AND api_name = $2`, + [sessionId, apiName], + ); + return parseInt(result.rows[0].count, 10); + } + + function createValidSignature( + clientKeypair: { privateKey: Bytes<32>; publicKey: Bytes<32> }, + sessionId: string, + authType: string, + idToken: string, + operationType: string, + apiName: string, + ): string { + const nodePubkeyHex = mockServerKeypair.publicKey.toHex(); + const message = `${nodePubkeyHex}${sessionId}${authType}${idToken}${operationType}${apiName}`; + const signRes = signMessage(message, clientKeypair.privateKey); + if (!signRes.success) throw new Error("Failed to sign message"); + + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) throw new Error("Failed to convert signature"); + + return sigBytesRes.data.toHex(); + } + + describe("replay attack prevention", () => { + it("should record api_call on successful response", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_up", + "keygen", + ); + + // Before call: no api_call record + expect(await getApiCallCount(sessionId, "keygen")).toBe(0); + + await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + // Wait for async finish handler + await new Promise((resolve) => setTimeout(resolve, 100)); + + // After call: api_call recorded + expect(await getApiCallCount(sessionId, "keygen")).toBe(1); + }); + + it("should not record api_call on failed response (retry allowed)", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_up", + "keygen", + ); + + await request(app) + .post("/test/keygen_fail") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(500); + + // Wait for async finish handler + await new Promise((resolve) => setTimeout(resolve, 100)); + + // api_call should not be recorded on failure + expect(await getApiCallCount(sessionId, "keygen")).toBe(0); + }); + + it("should return 409 when same API called twice with same session", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_in", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_in", + "signin", + ); + + // First call succeeds + await request(app) + .post("/test/signin") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + // Wait for async finish handler + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Reset session state to COMMITTED to allow second call attempt + await pool.query( + `UPDATE "commit_reveal_sessions" SET state = 'COMMITTED' WHERE session_id = $1`, + [sessionId], + ); + + // Second call with same signature should fail + const response = await request(app) + .post("/test/signin") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(409); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("API_ALREADY_CALLED"); + }); + }); + + describe("final API and session completion", () => { + it("should change session to COMPLETED on final API success", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_up", + "keygen", + ); + + // Before: COMMITTED + expect(await getSessionState(sessionId)).toBe("COMMITTED"); + + await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + // Wait for async finish handler + await new Promise((resolve) => setTimeout(resolve, 100)); + + // After: COMPLETED (keygen is final for sign_up) + expect(await getSessionState(sessionId)).toBe("COMPLETED"); + }); + + it("should keep session COMMITTED on non-final API success", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // sign_in_reshare allows signin (non-final) then reshare (final) + await createSession({ + session_id: sessionId, + operation_type: "sign_in_reshare", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_in_reshare", + "signin", + ); + + // Before: COMMITTED + expect(await getSessionState(sessionId)).toBe("COMMITTED"); + + await request(app) + .post("/test/signin") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + // Wait for async finish handler + await new Promise((resolve) => setTimeout(resolve, 100)); + + // After: still COMMITTED (signin is not final for sign_in_reshare) + expect(await getSessionState(sessionId)).toBe("COMMITTED"); + }); + + it("should not update session to COMPLETED on API failure", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_up", + "keygen", + ); + + // Before: COMMITTED + expect(await getSessionState(sessionId)).toBe("COMMITTED"); + + await request(app) + .post("/test/keygen_fail") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(500); + + // Wait for async finish handler + await new Promise((resolve) => setTimeout(resolve, 100)); + + // After: still COMMITTED (API failed) + expect(await getSessionState(sessionId)).toBe("COMMITTED"); + }); + }); + + describe("route integration tests", () => { + it("keygen route: should pass with sign_up operation", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_up", + "keygen", + ); + + const response = await request(app) + .post("/test/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.message).toBe("keygen ok"); + }); + + it("keygen_ed25519 route: should pass with add_ed25519 operation", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "add_ed25519", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "add_ed25519", + "keygen_ed25519", + ); + + const response = await request(app) + .post("/test/keygen_ed25519") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.message).toBe("keygen_ed25519 ok"); + }); + + it("signin route: should pass with sign_in operation", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_in", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_in", + "signin", + ); + + const response = await request(app) + .post("/test/signin") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.message).toBe("signin ok"); + }); + + it("reshare route: should pass with sign_in_reshare operation", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + await createSession({ + session_id: sessionId, + operation_type: "sign_in_reshare", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_in_reshare", + "reshare", + ); + + const response = await request(app) + .post("/test/reshare") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.message).toBe("reshare ok"); + }); + }); +}); diff --git a/backend/oko_api/server/src/middleware/commit_reveal.ts b/backend/oko_api/server/src/middleware/commit_reveal.ts new file mode 100644 index 000000000..31ba5ddb2 --- /dev/null +++ b/backend/oko_api/server/src/middleware/commit_reveal.ts @@ -0,0 +1,289 @@ +import type { Request, Response, NextFunction } from "express"; +import { Bytes } from "@oko-wallet/bytes"; +import { verifySignature } from "@oko-wallet/crypto-js/node/ecdhe"; +import { sha256 } from "@oko-wallet/crypto-js"; +import { + getCommitRevealSessionBySessionId, + createCommitRevealApiCall, + updateCommitRevealSessionState, + hasCommitRevealApiBeenCalled, +} from "@oko-wallet/oko-pg-interface/commit_reveal"; +import type { ApiName, CommitRevealSession } from "@oko-wallet/oko-types/commit_reveal"; +import { ErrorCodeMap } from "@oko-wallet/oko-api-error-codes"; + +import { + isApiAllowed, + isFinalApi, +} from "@oko-wallet-api/commit_reveal"; +import type { ServerState } from "@oko-wallet/oko-api-server-state"; + +const DEFAULT_AUTH_TYPE = "google"; + +export interface CommitRevealBody { + cr_session_id: string; + cr_signature: string; // 128 chars hex (64 bytes) + auth_type?: string; +} + +export function commitRevealMiddleware(apiName: ApiName) { + return async (req: Request, res: Response, next: NextFunction) => { + const state = req.app.locals as ServerState; + const body = req.body as CommitRevealBody; + const { cr_session_id, cr_signature } = body; + + if (!cr_session_id || !cr_signature) { + res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: "cr_session_id and cr_signature are required", + }); + return; + } + + const sessionResult = await getCommitRevealSessionBySessionId( + state.db, + cr_session_id, + ); + if (!sessionResult.success) { + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to get session: ${sessionResult.err}`, + }); + return; + } + + const session = sessionResult.data; + if (!session) { + res.status(ErrorCodeMap.SESSION_NOT_FOUND).json({ + success: false, + code: "SESSION_NOT_FOUND", + msg: "Session not found", + }); + return; + } + + if (session.state !== "COMMITTED") { + res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: `Session is not in COMMITTED state: ${session.state}`, + }); + return; + } + + if (new Date() > session.expires_at) { + res.status(ErrorCodeMap.SESSION_EXPIRED).json({ + success: false, + code: "SESSION_EXPIRED", + msg: "Session has expired", + }); + return; + } + + if (!isApiAllowed(session.operation_type, apiName)) { + res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: `API "${apiName}" is not allowed for operation "${session.operation_type}"`, + }); + return; + } + + // Check if this API has already been called for this session (replay attack prevention) + const apiCalledResult = await hasCommitRevealApiBeenCalled( + state.db, + cr_session_id, + apiName, + ); + if (!apiCalledResult.success) { + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to check API call status: ${apiCalledResult.err}`, + }); + return; + } + if (apiCalledResult.data) { + res.status(ErrorCodeMap.API_ALREADY_CALLED).json({ + success: false, + code: "API_ALREADY_CALLED", + msg: `API "${apiName}" has already been called for this session`, + }); + return; + } + + const signatureRes = Bytes.fromHexString(cr_signature, 64); + if (!signatureRes.success) { + res.status(ErrorCodeMap.INVALID_SIGNATURE).json({ + success: false, + code: "INVALID_SIGNATURE", + msg: `Invalid signature format: ${signatureRes.err}`, + }); + return; + } + + const clientPubkeyRes = Bytes.fromUint8Array( + session.client_ephemeral_pubkey, + 32, + ); + if (!clientPubkeyRes.success) { + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to parse client pubkey: ${clientPubkeyRes.err}`, + }); + return; + } + + // Get auth_type and id_token from request + const authType = body.auth_type ?? DEFAULT_AUTH_TYPE; + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + res.status(401).json({ + success: false, + code: "UNAUTHORIZED", + msg: "Authorization header with Bearer token required", + }); + return; + } + const idToken = authHeader.substring(7).trim(); + + // Verify id_token_hash matches committed hash + const computedHashRes = sha256(`${authType}${idToken}`); + if (!computedHashRes.success) { + res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to compute id_token_hash: ${computedHashRes.err}`, + }); + return; + } + const computedHash = computedHashRes.data.toHex(); + if (computedHash !== session.id_token_hash) { + res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: "id_token_hash mismatch: token does not match committed session", + }); + return; + } + + // Verify signature: message = node_pubkey + session_id + auth_type + id_token + operation_type + api_name + const nodePubkeyHex = state.server_keypair.publicKey.toHex(); + const message = makeSigMessage({ + nodePubkeyHex, + cr_session_id, + authType, + idToken, + session, + apiName, + }); + const rBytes = Bytes.fromUint8Array( + signatureRes.data.toUint8Array().slice(0, 32), + 32, + ); + const sBytes = Bytes.fromUint8Array( + signatureRes.data.toUint8Array().slice(32, 64), + 32, + ); + if (!rBytes.success || !sBytes.success) { + res.status(ErrorCodeMap.INVALID_SIGNATURE).json({ + success: false, + code: "INVALID_SIGNATURE", + msg: "Failed to parse signature components", + }); + return; + } + + const verifyResult = verifySignature( + message, + { r: rBytes.data, s: sBytes.data }, + clientPubkeyRes.data, + ); + if (!verifyResult.success) { + res.status(ErrorCodeMap.INVALID_SIGNATURE).json({ + success: false, + code: "INVALID_SIGNATURE", + msg: `Signature verification failed: ${verifyResult.err}`, + }); + return; + } + if (!verifyResult.data) { + res.status(ErrorCodeMap.INVALID_SIGNATURE).json({ + success: false, + code: "INVALID_SIGNATURE", + msg: "Invalid signature", + }); + return; + } + + res.locals.cr_session_id = cr_session_id; + + // Record API call and update session state on successful response + res.on("finish", async () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + const client = await state.db.connect(); + try { + await client.query("BEGIN"); + + await createCommitRevealApiCall( + client, + cr_session_id, + apiName, + signatureRes.data.toUint8Array(), + ); + + if (isFinalApi(session.operation_type, apiName)) { + await updateCommitRevealSessionState( + client, + cr_session_id, + "COMPLETED", + ); + } + + await client.query("COMMIT"); + } catch (err) { + await client.query("ROLLBACK"); + state.logger.error( + "Failed to record API call for session %s: %s", + cr_session_id, + err, + ); + } finally { + client.release(); + } + } + }); + + next(); + }; +} + +export interface SigMessageArgs { + nodePubkeyHex: string; + cr_session_id: string; + authType: string; + idToken: string; + session: CommitRevealSession; + apiName: ApiName; +} + +// message = node_pubkey + session_id + auth_type + id_token + operation_type + api_name +function makeSigMessage({ + nodePubkeyHex, + cr_session_id, + authType, + idToken, + session, + apiName, +}: SigMessageArgs) { + return ( + nodePubkeyHex + + cr_session_id + + authType + + idToken + + session.operation_type + + apiName + ); +} diff --git a/backend/oko_api/server/src/routes/tss_v2/commit.test.ts b/backend/oko_api/server/src/routes/tss_v2/commit.test.ts new file mode 100644 index 000000000..0c2a2779d --- /dev/null +++ b/backend/oko_api/server/src/routes/tss_v2/commit.test.ts @@ -0,0 +1,410 @@ +import request from "supertest"; +import express from "express"; +import type { Pool } from "pg"; +import { Bytes } from "@oko-wallet/bytes"; +import { randomBytes } from "node:crypto"; +import { v4 as uuidv4 } from "uuid"; +import { createPgConn } from "@oko-wallet/postgres-lib"; +import winston from "winston"; + +import { testPgConfig } from "@oko-wallet-api/database/test_config"; +import { resetPgDatabase } from "@oko-wallet-api/testing/database"; +import { commitRevealCommit } from "./commit"; + +// Mock keypair for testing +const privateKeyRes = Bytes.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000001", + 32, +); +const publicKeyRes = Bytes.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000002", + 32, +); +if (!privateKeyRes.success || !publicKeyRes.success) { + throw new Error("Failed to create mock keypair"); +} +const mockServerKeypair = { + privateKey: privateKeyRes.data, + publicKey: publicKeyRes.data, +}; + +const testLogger = winston.createLogger({ + level: "error", + silent: true, + transports: [new winston.transports.Console()], +}); + +function generateRandomHex(bytes: number): string { + return randomBytes(bytes).toString("hex"); +} + +describe("commit_route_test", () => { + let pool: Pool; + let app: express.Application; + + beforeAll(async () => { + const config = testPgConfig; + const createPostgresRes = await createPgConn({ + database: config.database, + host: config.host, + password: config.password, + user: config.user, + port: config.port, + ssl: config.ssl, + }); + + if (createPostgresRes.success === false) { + console.error(createPostgresRes.err); + throw new Error("Failed to create postgres database"); + } + + pool = createPostgresRes.data; + + app = express(); + app.use(express.json()); + + app.post("/tss/v2/commit", commitRevealCommit); + + app.locals.db = pool; + app.locals.server_keypair = mockServerKeypair; + app.locals.logger = testLogger; + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + }); + + afterAll(async () => { + await pool.end(); + }); + + const testEndpoint = "/tss/v2/commit"; + + const createValidBody = () => ({ + session_id: uuidv4(), + operation_type: "sign_in", + client_ephemeral_pubkey: generateRandomHex(32), + id_token_hash: generateRandomHex(32), + }); + + describe("success cases", () => { + it("should successfully create session with sign_in operation", async () => { + const body = createValidBody(); + + const response = await request(app) + .post(testEndpoint) + .send(body) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.node_pubkey).toBe( + mockServerKeypair.publicKey.toHex(), + ); + expect(response.body.data.node_signature).toBeDefined(); + expect(response.body.data.node_signature).toHaveLength(128); // 64 bytes hex + }); + + it("should successfully create session with sign_up operation", async () => { + const body = { + ...createValidBody(), + operation_type: "sign_up", + }; + + const response = await request(app) + .post(testEndpoint) + .send(body) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(response.body.data.node_pubkey).toBeDefined(); + expect(response.body.data.node_signature).toBeDefined(); + }); + + it("should successfully create session with sign_in_reshare operation", async () => { + const body = { + ...createValidBody(), + operation_type: "sign_in_reshare", + }; + + const response = await request(app) + .post(testEndpoint) + .send(body) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + }); + + it("should successfully create session with add_ed25519 operation", async () => { + const body = { + ...createValidBody(), + operation_type: "add_ed25519", + }; + + const response = await request(app) + .post(testEndpoint) + .send(body) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + }); + + it("should create multiple sessions with different session_ids", async () => { + const body1 = createValidBody(); + const body2 = createValidBody(); + const body3 = createValidBody(); + + const response1 = await request(app) + .post(testEndpoint) + .send(body1) + .expect(200); + const response2 = await request(app) + .post(testEndpoint) + .send(body2) + .expect(200); + const response3 = await request(app) + .post(testEndpoint) + .send(body3) + .expect(200); + + expect(response1.body.success).toBe(true); + expect(response2.body.success).toBe(true); + expect(response3.body.success).toBe(true); + }); + }); + + describe("session verification", () => { + it("should create session in COMMITTED state", async () => { + const body = createValidBody(); + + await request(app).post(testEndpoint).send(body).expect(200); + + // Verify session was created in DB + const result = await pool.query( + 'SELECT * FROM "commit_reveal_sessions" WHERE session_id = $1', + [body.session_id], + ); + + expect(result.rows.length).toBe(1); + expect(result.rows[0].state).toBe("COMMITTED"); + expect(result.rows[0].operation_type).toBe(body.operation_type); + expect(result.rows[0].id_token_hash).toBe(body.id_token_hash); + }); + + it("should set expires_at to approximately 5 minutes from now", async () => { + const body = createValidBody(); + const beforeRequest = new Date(); + + await request(app).post(testEndpoint).send(body).expect(200); + + const afterRequest = new Date(); + + const result = await pool.query( + 'SELECT * FROM "commit_reveal_sessions" WHERE session_id = $1', + [body.session_id], + ); + + expect(result.rows.length).toBe(1); + + const expiresAt = new Date(result.rows[0].expires_at); + const expectedMinExpiresAt = new Date( + beforeRequest.getTime() + 5 * 60 * 1000 - 1000, + ); + const expectedMaxExpiresAt = new Date( + afterRequest.getTime() + 5 * 60 * 1000 + 1000, + ); + + expect(expiresAt.getTime()).toBeGreaterThanOrEqual( + expectedMinExpiresAt.getTime(), + ); + expect(expiresAt.getTime()).toBeLessThanOrEqual( + expectedMaxExpiresAt.getTime(), + ); + }); + }); + + describe("duplicate key errors", () => { + it("should return 409 when session_id already exists", async () => { + const sessionId = uuidv4(); + const body1 = { + ...createValidBody(), + session_id: sessionId, + }; + const body2 = { + ...createValidBody(), + session_id: sessionId, + }; + + await request(app).post(testEndpoint).send(body1).expect(200); + + const response = await request(app) + .post(testEndpoint) + .send(body2) + .expect(409); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("SESSION_ALREADY_EXISTS"); + }); + + it("should return 409 when client_ephemeral_pubkey already exists", async () => { + const pubkey = generateRandomHex(32); + const body1 = { + ...createValidBody(), + client_ephemeral_pubkey: pubkey, + }; + const body2 = { + ...createValidBody(), + client_ephemeral_pubkey: pubkey, + }; + + await request(app).post(testEndpoint).send(body1).expect(200); + + const response = await request(app) + .post(testEndpoint) + .send(body2) + .expect(409); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("SESSION_ALREADY_EXISTS"); + }); + + it("should return 409 when id_token_hash already exists", async () => { + const idTokenHash = generateRandomHex(32); + const body1 = { + ...createValidBody(), + id_token_hash: idTokenHash, + }; + const body2 = { + ...createValidBody(), + id_token_hash: idTokenHash, + }; + + await request(app).post(testEndpoint).send(body1).expect(200); + + const response = await request(app) + .post(testEndpoint) + .send(body2) + .expect(409); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("SESSION_ALREADY_EXISTS"); + }); + }); + + describe("invalid input errors", () => { + it("should return 400 when client_ephemeral_pubkey is invalid hex", async () => { + const body = { + ...createValidBody(), + client_ephemeral_pubkey: "invalid_hex_string", + }; + + const response = await request(app) + .post(testEndpoint) + .send(body) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("client_ephemeral_pubkey"); + }); + + it("should return 400 when client_ephemeral_pubkey is wrong length", async () => { + const body = { + ...createValidBody(), + client_ephemeral_pubkey: generateRandomHex(16), // 16 bytes instead of 32 + }; + + const response = await request(app) + .post(testEndpoint) + .send(body) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("client_ephemeral_pubkey"); + }); + + it("should return 400 when id_token_hash is invalid hex", async () => { + const body = { + ...createValidBody(), + id_token_hash: "invalid_hex_string", + }; + + const response = await request(app) + .post(testEndpoint) + .send(body) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("id_token_hash"); + }); + + it("should return 400 when id_token_hash is wrong length", async () => { + const body = { + ...createValidBody(), + id_token_hash: generateRandomHex(16), // 16 bytes instead of 32 + }; + + const response = await request(app) + .post(testEndpoint) + .send(body) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("id_token_hash"); + }); + + it("should return 500 when session_id is missing", async () => { + const body = createValidBody(); + const { session_id, ...bodyWithoutSessionId } = body; + + const response = await request(app) + .post(testEndpoint) + .send(bodyWithoutSessionId) + .expect(500); + + expect(response.body.success).toBe(false); + }); + + it("should return 500 when operation_type is missing", async () => { + const body = createValidBody(); + const { operation_type, ...bodyWithoutOperationType } = body; + + const response = await request(app) + .post(testEndpoint) + .send(bodyWithoutOperationType) + .expect(500); + + expect(response.body.success).toBe(false); + }); + + it("should return 400 when client_ephemeral_pubkey is missing", async () => { + const body = createValidBody(); + const { client_ephemeral_pubkey, ...bodyWithoutPubkey } = body; + + const response = await request(app) + .post(testEndpoint) + .send(bodyWithoutPubkey) + .expect(400); + + expect(response.body.success).toBe(false); + }); + + it("should return 400 when id_token_hash is missing", async () => { + const body = createValidBody(); + const { id_token_hash, ...bodyWithoutHash } = body; + + const response = await request(app) + .post(testEndpoint) + .send(bodyWithoutHash) + .expect(400); + + expect(response.body.success).toBe(false); + }); + }); +}); diff --git a/backend/oko_api/server/src/routes/tss_v2/commit.ts b/backend/oko_api/server/src/routes/tss_v2/commit.ts new file mode 100644 index 000000000..4b39fd136 --- /dev/null +++ b/backend/oko_api/server/src/routes/tss_v2/commit.ts @@ -0,0 +1,173 @@ +import type { Request, Response } from "express"; +import { Bytes } from "@oko-wallet/bytes"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import { + signMessage, + convertEddsaSignatureToBytes, +} from "@oko-wallet/crypto-js/node/ecdhe"; +import { createCommitRevealSession } from "@oko-wallet/oko-pg-interface/commit_reveal"; +import type { ServerState } from "@oko-wallet/oko-api-server-state"; + +import { registry } from "@oko-wallet/oko-api-openapi"; +import { ErrorResponseSchema } from "@oko-wallet/oko-api-openapi/common"; +import { + CommitRequestBodySchema, + CommitSuccessResponseSchema, + type CommitRequestBody, + type CommitResponseData, +} from "@oko-wallet/oko-api-openapi/tss"; + +const SESSION_EXPIRY_MINUTES = 5; + +registry.registerPath({ + method: "post", + path: "/tss/v2/commit", + tags: ["TSS"], + summary: "Create a commit-reveal session", + description: + "Creates a new commit-reveal session for frontrunning prevention. Returns node's public key and signature.", + request: { + body: { + required: true, + content: { + "application/json": { + schema: CommitRequestBodySchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Successfully created session", + content: { + "application/json": { + schema: CommitSuccessResponseSchema, + }, + }, + }, + 400: { + description: "Bad request - invalid input", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + INVALID_REQUEST: { + value: { + success: false, + code: "INVALID_REQUEST", + msg: "Invalid client_ephemeral_pubkey format", + }, + }, + }, + }, + }, + }, + 409: { + description: "Conflict - session already exists", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + SESSION_ALREADY_EXISTS: { + value: { + success: false, + code: "SESSION_ALREADY_EXISTS", + msg: "Session with this id_token_hash already exists", + }, + }, + }, + }, + }, + }, + 500: { + description: "Internal server error", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +export async function commitRevealCommit( + req: Request, + res: Response>, +) { + const state = req.app.locals as ServerState; + const body = req.body; + + // Validate client_ephemeral_pubkey (32 bytes hex) + const clientPubkeyRes = Bytes.fromHexString(body.client_ephemeral_pubkey, 32); + if (!clientPubkeyRes.success) { + return res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: `Invalid client_ephemeral_pubkey: ${clientPubkeyRes.err}`, + }); + } + + // Validate id_token_hash (32 bytes hex) + const idTokenHashRes = Bytes.fromHexString(body.id_token_hash, 32); + if (!idTokenHashRes.success) { + return res.status(400).json({ + success: false, + code: "INVALID_REQUEST", + msg: `Invalid id_token_hash: ${idTokenHashRes.err}`, + }); + } + + const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MINUTES * 60 * 1000); + + const createResult = await createCommitRevealSession(state.db, { + session_id: body.session_id, + operation_type: body.operation_type, + client_ephemeral_pubkey: clientPubkeyRes.data.toUint8Array(), + id_token_hash: body.id_token_hash, + expires_at: expiresAt, + }); + + if (!createResult.success) { + // Check for duplicate key errors + if (createResult.err.includes("duplicate key")) { + return res.status(409).json({ + success: false, + code: "SESSION_ALREADY_EXISTS", + msg: "Session with this session_id, client_ephemeral_pubkey, or id_token_hash already exists", + }); + } + return res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to create session: ${createResult.err}`, + }); + } + + // Sign the node's public key with the node's private key + const nodePubkeyHex = state.server_keypair.publicKey.toHex(); + const signResult = signMessage(nodePubkeyHex, state.server_keypair.privateKey); + if (!signResult.success) { + return res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to sign node public key: ${signResult.err}`, + }); + } + + const signatureBytesRes = convertEddsaSignatureToBytes(signResult.data); + if (!signatureBytesRes.success) { + return res.status(500).json({ + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to convert signature: ${signatureBytesRes.err}`, + }); + } + + return res.status(200).json({ + success: true, + data: { + node_pubkey: nodePubkeyHex, + node_signature: signatureBytesRes.data.toHex(), + }, + }); +} diff --git a/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts b/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts new file mode 100644 index 000000000..04ab69133 --- /dev/null +++ b/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts @@ -0,0 +1,786 @@ +import request from "supertest"; +import express from "express"; +import type { Pool } from "pg"; +import { Bytes } from "@oko-wallet/bytes"; +import { randomBytes } from "node:crypto"; +import { v4 as uuidv4 } from "uuid"; +import { createPgConn } from "@oko-wallet/postgres-lib"; +import winston from "winston"; +import { sha256 } from "@oko-wallet/crypto-js"; +import { + generateEddsaKeypair, + signMessage, + convertEddsaSignatureToBytes, +} from "@oko-wallet/crypto-js/node/ecdhe"; + +import { testPgConfig } from "@oko-wallet-api/database/test_config"; +import { resetPgDatabase } from "@oko-wallet-api/testing/database"; +import { commitRevealCommit } from "./commit"; +import { commitRevealMiddleware } from "@oko-wallet-api/middleware/commit_reveal"; + +// Mock keypair for testing +const privateKeyRes = Bytes.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000001", + 32, +); +const publicKeyRes = Bytes.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000002", + 32, +); +if (!privateKeyRes.success || !publicKeyRes.success) { + throw new Error("Failed to create mock keypair"); +} +const mockServerKeypair = { + privateKey: privateKeyRes.data, + publicKey: publicKeyRes.data, +}; + +const testLogger = winston.createLogger({ + level: "error", + silent: true, + transports: [new winston.transports.Console()], +}); + +function generateRandomHex(bytes: number): string { + return randomBytes(bytes).toString("hex"); +} + +describe("tss_v2_commit_reveal_e2e_test", () => { + let pool: Pool; + let app: express.Application; + + beforeAll(async () => { + const config = testPgConfig; + const createPostgresRes = await createPgConn({ + database: config.database, + host: config.host, + password: config.password, + user: config.user, + port: config.port, + ssl: config.ssl, + }); + + if (createPostgresRes.success === false) { + console.error(createPostgresRes.err); + throw new Error("Failed to create postgres database"); + } + + pool = createPostgresRes.data; + + app = express(); + app.use(express.json()); + + // Commit endpoint (no middleware) + app.post("/tss/v2/commit", commitRevealCommit); + + // Protected endpoints with commit-reveal middleware + // Mock handlers that simulate successful API calls + app.post( + "/tss/v2/keygen", + commitRevealMiddleware("keygen"), + (_req, res) => { + res.status(200).json({ + success: true, + data: { wallet_id: uuidv4(), public_key: generateRandomHex(33) }, + }); + }, + ); + + app.post( + "/tss/v2/keygen_ed25519", + commitRevealMiddleware("keygen_ed25519"), + (_req, res) => { + res.status(200).json({ + success: true, + data: { wallet_id: uuidv4(), public_key: generateRandomHex(32) }, + }); + }, + ); + + app.post( + "/tss/v2/user/signin", + commitRevealMiddleware("signin"), + (_req, res) => { + res.status(200).json({ + success: true, + data: { token: "mock_jwt_token", user_id: uuidv4() }, + }); + }, + ); + + app.post( + "/tss/v2/user/reshare", + commitRevealMiddleware("reshare"), + (_req, res) => { + res.status(200).json({ + success: true, + data: { reshare_complete: true }, + }); + }, + ); + + app.locals.db = pool; + app.locals.server_keypair = mockServerKeypair; + app.locals.logger = testLogger; + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + }); + + afterAll(async () => { + await pool.end(); + }); + + function createValidSignature( + clientKeypair: { privateKey: Bytes<32>; publicKey: Bytes<32> }, + sessionId: string, + authType: string, + idToken: string, + operationType: string, + apiName: string, + ): string { + const nodePubkeyHex = mockServerKeypair.publicKey.toHex(); + const message = `${nodePubkeyHex}${sessionId}${authType}${idToken}${operationType}${apiName}`; + const signRes = signMessage(message, clientKeypair.privateKey); + if (!signRes.success) throw new Error("Failed to sign message"); + + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) throw new Error("Failed to convert signature"); + + return sigBytesRes.data.toHex(); + } + + async function getSessionState(sessionId: string): Promise { + const result = await pool.query( + `SELECT state FROM "commit_reveal_sessions" WHERE session_id = $1`, + [sessionId], + ); + return result.rows[0]?.state ?? null; + } + + async function getApiCallCount(sessionId: string, apiName: string): Promise { + const result = await pool.query( + `SELECT COUNT(*) as count FROM "commit_reveal_api_calls" WHERE session_id = $1 AND api_name = $2`, + [sessionId, apiName], + ); + return parseInt(result.rows[0].count, 10); + } + + describe("sign_up flow (commit -> keygen)", () => { + it("should complete full sign_up flow: commit -> keygen -> verify data", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token_for_signup"; + + // Generate client keypair + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + // Compute id_token_hash + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + const idTokenHash = hashRes.data.toHex(); + + // Step 1: Commit + const commitResponse = await request(app) + .post("/tss/v2/commit") + .send({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: idTokenHash, + }) + .expect(200); + + expect(commitResponse.body.success).toBe(true); + expect(commitResponse.body.data.node_pubkey).toBe(mockServerKeypair.publicKey.toHex()); + expect(commitResponse.body.data.node_signature).toHaveLength(128); + + // Verify session is in COMMITTED state + expect(await getSessionState(sessionId)).toBe("COMMITTED"); + + // Step 2: Keygen with commit-reveal signature + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_up", + "keygen", + ); + + const keygenResponse = await request(app) + .post("/tss/v2/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + expect(keygenResponse.body.success).toBe(true); + expect(keygenResponse.body.data.wallet_id).toBeDefined(); + expect(keygenResponse.body.data.public_key).toBeDefined(); + + // Wait for async finish handler + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify session is now COMPLETED + expect(await getSessionState(sessionId)).toBe("COMPLETED"); + + // Verify API call was recorded + expect(await getApiCallCount(sessionId, "keygen")).toBe(1); + }); + + it("should reject replay attack on keygen API", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token_replay"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Commit + await request(app) + .post("/tss/v2/commit") + .send({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }) + .expect(200); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_up", + "keygen", + ); + + // First keygen call - success + await request(app) + .post("/tss/v2/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Reset session state to COMMITTED to allow second attempt + await pool.query( + `UPDATE "commit_reveal_sessions" SET state = 'COMMITTED' WHERE session_id = $1`, + [sessionId], + ); + + // Second keygen call - should be rejected (replay attack) + const replayResponse = await request(app) + .post("/tss/v2/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(409); + + expect(replayResponse.body.success).toBe(false); + expect(replayResponse.body.code).toBe("API_ALREADY_CALLED"); + }); + }); + + describe("sign_in flow (commit -> signin)", () => { + it("should complete full sign_in flow", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token_for_signin"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Commit + const commitResponse = await request(app) + .post("/tss/v2/commit") + .send({ + session_id: sessionId, + operation_type: "sign_in", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }) + .expect(200); + + expect(commitResponse.body.success).toBe(true); + + // Signin + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_in", + "signin", + ); + + const signinResponse = await request(app) + .post("/tss/v2/user/signin") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + expect(signinResponse.body.success).toBe(true); + expect(signinResponse.body.data.token).toBeDefined(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Session should be COMPLETED (signin is final for sign_in) + expect(await getSessionState(sessionId)).toBe("COMPLETED"); + }); + }); + + describe("sign_in_reshare flow (commit -> signin -> reshare)", () => { + it("should complete full sign_in_reshare flow", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token_for_reshare"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Commit with sign_in_reshare operation + await request(app) + .post("/tss/v2/commit") + .send({ + session_id: sessionId, + operation_type: "sign_in_reshare", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }) + .expect(200); + + // Step 1: Signin (non-final for sign_in_reshare) + const signinSignature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_in_reshare", + "signin", + ); + + await request(app) + .post("/tss/v2/user/signin") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signinSignature, + auth_type: authType, + }) + .expect(200); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Session should still be COMMITTED (signin is not final for sign_in_reshare) + expect(await getSessionState(sessionId)).toBe("COMMITTED"); + + // Step 2: Reshare (final for sign_in_reshare) + const reshareSignature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_in_reshare", + "reshare", + ); + + const reshareResponse = await request(app) + .post("/tss/v2/user/reshare") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: reshareSignature, + auth_type: authType, + }) + .expect(200); + + expect(reshareResponse.body.success).toBe(true); + expect(reshareResponse.body.data.reshare_complete).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Session should now be COMPLETED + expect(await getSessionState(sessionId)).toBe("COMPLETED"); + + // Both API calls should be recorded + expect(await getApiCallCount(sessionId, "signin")).toBe(1); + expect(await getApiCallCount(sessionId, "reshare")).toBe(1); + }); + }); + + describe("add_ed25519 flow (commit -> keygen_ed25519)", () => { + it("should complete full add_ed25519 flow", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token_for_ed25519"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Commit + await request(app) + .post("/tss/v2/commit") + .send({ + session_id: sessionId, + operation_type: "add_ed25519", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }) + .expect(200); + + // Keygen ed25519 + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "add_ed25519", + "keygen_ed25519", + ); + + const keygenResponse = await request(app) + .post("/tss/v2/keygen_ed25519") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(200); + + expect(keygenResponse.body.success).toBe(true); + expect(keygenResponse.body.data.wallet_id).toBeDefined(); + expect(keygenResponse.body.data.public_key).toBeDefined(); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Session should be COMPLETED + expect(await getSessionState(sessionId)).toBe("COMPLETED"); + expect(await getApiCallCount(sessionId, "keygen_ed25519")).toBe(1); + }); + }); +}); + +describe("tss_v2_e2e_error_scenarios", () => { + let pool: Pool; + let app: express.Application; + + beforeAll(async () => { + const config = testPgConfig; + const createPostgresRes = await createPgConn({ + database: config.database, + host: config.host, + password: config.password, + user: config.user, + port: config.port, + ssl: config.ssl, + }); + + if (createPostgresRes.success === false) { + console.error(createPostgresRes.err); + throw new Error("Failed to create postgres database"); + } + + pool = createPostgresRes.data; + + app = express(); + app.use(express.json()); + + app.post("/tss/v2/commit", commitRevealCommit); + + app.post("/tss/v2/keygen", commitRevealMiddleware("keygen"), (_req, res) => { + res.status(200).json({ success: true, data: { message: "keygen ok" } }); + }); + + app.post("/tss/v2/user/signin", commitRevealMiddleware("signin"), (_req, res) => { + res.status(200).json({ success: true, data: { message: "signin ok" } }); + }); + + app.locals.db = pool; + app.locals.server_keypair = mockServerKeypair; + app.locals.logger = testLogger; + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + }); + + afterAll(async () => { + await pool.end(); + }); + + function createValidSignature( + clientKeypair: { privateKey: Bytes<32>; publicKey: Bytes<32> }, + sessionId: string, + authType: string, + idToken: string, + operationType: string, + apiName: string, + ): string { + const nodePubkeyHex = mockServerKeypair.publicKey.toHex(); + const message = `${nodePubkeyHex}${sessionId}${authType}${idToken}${operationType}${apiName}`; + const signRes = signMessage(message, clientKeypair.privateKey); + if (!signRes.success) throw new Error("Failed to sign message"); + + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) throw new Error("Failed to convert signature"); + + return sigBytesRes.data.toHex(); + } + + describe("error scenarios", () => { + it("should reject invalid signature", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Commit + await request(app) + .post("/tss/v2/commit") + .send({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }) + .expect(200); + + // Try keygen with wrong signature (signed with wrong message) + const wrongSignature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_up", + "wrong_api", // wrong api name in signature + ); + + const response = await request(app) + .post("/tss/v2/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: wrongSignature, + auth_type: authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + + it("should reject expired session", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Commit + await request(app) + .post("/tss/v2/commit") + .send({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }) + .expect(200); + + // Manually expire the session + await pool.query( + `UPDATE "commit_reveal_sessions" SET expires_at = $1 WHERE session_id = $2`, + [new Date(Date.now() - 1000), sessionId], + ); + + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_up", + "keygen", + ); + + const response = await request(app) + .post("/tss/v2/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(410); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("SESSION_EXPIRED"); + }); + + it("should reject non-existent session", async () => { + const nonExistentSessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const response = await request(app) + .post("/tss/v2/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: nonExistentSessionId, + cr_signature: generateRandomHex(64), + auth_type: authType, + }) + .expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("SESSION_NOT_FOUND"); + }); + + it("should reject wrong operation type for API", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const idToken = "test_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Commit with sign_in operation + await request(app) + .post("/tss/v2/commit") + .send({ + session_id: sessionId, + operation_type: "sign_in", // sign_in operation + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }) + .expect(200); + + // Try to call keygen (not allowed for sign_in) + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + idToken, + "sign_in", + "keygen", + ); + + const response = await request(app) + .post("/tss/v2/keygen") + .set("Authorization", `Bearer ${idToken}`) + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("keygen"); + expect(response.body.msg).toContain("sign_in"); + }); + + it("should reject id_token_hash mismatch", async () => { + const sessionId = uuidv4(); + const authType = "google"; + const originalIdToken = "original_id_token"; + const wrongIdToken = "wrong_id_token"; + + const clientKeypairRes = generateEddsaKeypair(); + if (!clientKeypairRes.success) throw new Error("Failed to generate keypair"); + const clientKeypair = clientKeypairRes.data; + + // Hash with original token + const hashRes = sha256(`${authType}${originalIdToken}`); + if (!hashRes.success) throw new Error("Failed to compute hash"); + + // Commit with original token hash + await request(app) + .post("/tss/v2/commit") + .send({ + session_id: sessionId, + operation_type: "sign_up", + client_ephemeral_pubkey: clientKeypair.publicKey.toHex(), + id_token_hash: hashRes.data.toHex(), + }) + .expect(200); + + // Try keygen with wrong id_token (different from committed hash) + const signature = createValidSignature( + clientKeypair, + sessionId, + authType, + wrongIdToken, // wrong token + "sign_up", + "keygen", + ); + + const response = await request(app) + .post("/tss/v2/keygen") + .set("Authorization", `Bearer ${wrongIdToken}`) // wrong token in header + .send({ + cr_session_id: sessionId, + cr_signature: signature, + auth_type: authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("id_token_hash mismatch"); + }); + }); +}); diff --git a/backend/oko_api/server/src/routes/tss_v2/index.ts b/backend/oko_api/server/src/routes/tss_v2/index.ts index 767a5b144..0a15e20ec 100644 --- a/backend/oko_api/server/src/routes/tss_v2/index.ts +++ b/backend/oko_api/server/src/routes/tss_v2/index.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import { oauthMiddleware } from "@oko-wallet-api/middleware/auth/oauth"; import { tssActivateMiddleware } from "@oko-wallet-api/middleware/auth/tss_activate"; +import { commitRevealMiddleware } from "@oko-wallet-api/middleware/commit_reveal"; import { keygenV2 } from "./keygen"; import { userJwtMiddlewareV2 } from "@oko-wallet-api/middleware/auth/keplr_auth"; import { presignStep1 } from "./presign_step_1"; @@ -27,15 +28,25 @@ import { keygenEd25519 } from "./keygen_ed25519"; import { userSignInV2 } from "./user_signin"; import { userReshareV2 } from "./user_reshare"; import { userCheckEmailV2 } from "./user_check_email"; +import { commitRevealCommit } from "./commit"; export function makeTSSRouterV2() { const router = Router(); - router.post("/keygen", oauthMiddleware, tssActivateMiddleware, keygenV2); + router.post("/commit", commitRevealCommit); + + router.post( + "/keygen", + oauthMiddleware, + commitRevealMiddleware("keygen"), + tssActivateMiddleware, + keygenV2, + ); router.post( "/keygen_ed25519", oauthMiddleware, + commitRevealMiddleware("keygen_ed25519"), tssActivateMiddleware, keygenEd25519, ); @@ -151,6 +162,7 @@ export function makeTSSRouterV2() { router.post( "/user/signin", oauthMiddleware, + commitRevealMiddleware("signin"), tssActivateMiddleware, userSignInV2, ); @@ -158,6 +170,7 @@ export function makeTSSRouterV2() { router.post( "/user/reshare", oauthMiddleware, + commitRevealMiddleware("reshare"), tssActivateMiddleware, userReshareV2, ); diff --git a/backend/oko_api_error_codes/src/index.ts b/backend/oko_api_error_codes/src/index.ts index eeb4a74f8..c19fbe4e1 100644 --- a/backend/oko_api_error_codes/src/index.ts +++ b/backend/oko_api_error_codes/src/index.ts @@ -42,5 +42,10 @@ export const ErrorCodeMap: Record = { INVALID_PUBLIC_KEY: 400, INVALID_WALLET_TYPE: 400, REFERRAL_NOT_FOUND: 404, + SESSION_ALREADY_EXISTS: 409, + SESSION_NOT_FOUND: 404, + SESSION_EXPIRED: 410, + API_ALREADY_CALLED: 409, + INVALID_SIGNATURE: 400, UNKNOWN_ERROR: 500, }; diff --git a/backend/openapi/src/tss/commit_reveal.ts b/backend/openapi/src/tss/commit_reveal.ts new file mode 100644 index 000000000..0109d0fde --- /dev/null +++ b/backend/openapi/src/tss/commit_reveal.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; +import { registry } from "@oko-wallet/oko-api-openapi"; + +export const OperationTypeSchema = z + .enum(["sign_in", "sign_up", "sign_in_reshare", "add_ed25519"]) + .describe("Operation type for commit-reveal session"); + +// POST /tss/v2/commit-reveal/commit + +export const CommitRequestBodySchema = registry.register( + "CommitRevealCommitRequestBody", + z + .object({ + session_id: z + .string() + .uuid() + .describe("Client-generated session ID (UUIDv4)") + .openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }), + operation_type: OperationTypeSchema, + client_ephemeral_pubkey: z + .string() + .length(64) + .describe("Client ephemeral public key (32 bytes hex)") + .openapi({ + example: + "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd", + }), + id_token_hash: z + .string() + .length(64) + .describe("SHA-256 hash of (auth_type | id_token) (32 bytes hex)") + .openapi({ + example: + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + }), + }) + .openapi("CommitRevealCommitRequestBody", { + description: "Request payload for creating a commit-reveal session.", + }), +); + +export const CommitResponseDataSchema = registry.register( + "CommitRevealCommitResponseData", + z + .object({ + node_pubkey: z + .string() + .length(64) + .describe("Node's public key (32 bytes hex)") + .openapi({ + example: + "b2c3d4e5f67890123456789012345678901234567890123456789012345678ef", + }), + node_signature: z + .string() + .length(128) + .describe("Node's signature on node_pubkey (64 bytes hex)") + .openapi({ + example: + "c3d4e5f6789012345678901234567890123456789012345678901234567890abc3d4e5f6789012345678901234567890123456789012345678901234567890ab", + }), + }) + .openapi("CommitRevealCommitResponseData", { + description: "Response data containing node's public key and signature.", + }), +); + +export const CommitSuccessResponseSchema = registry.register( + "CommitRevealCommitSuccessResponse", + z + .object({ + success: z.literal(true), + data: CommitResponseDataSchema, + }) + .openapi("CommitRevealCommitSuccessResponse", { + description: "Success response for commit request.", + }), +); + +export type CommitRequestBody = z.infer; +export type CommitResponseData = z.infer; + +// Commit-reveal request fields (used by protected endpoints) + +export const CommitRevealRequestFieldsSchema = z.object({ + cr_session_id: z + .string() + .uuid() + .describe("Commit-reveal session ID from commit API") + .openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }), + cr_signature: z + .string() + .length(128) + .describe( + "Client signature: sign(node_pubkey + session_id + auth_type + id_token + operation_type + api_name) (64 bytes hex)", + ) + .openapi({ + example: + "c3d4e5f6789012345678901234567890123456789012345678901234567890abc3d4e5f6789012345678901234567890123456789012345678901234567890ab", + }), + auth_type: z + .string() + .optional() + .describe("Authentication type (defaults to 'google')") + .openapi({ example: "google" }), +}); diff --git a/backend/openapi/src/tss/index.ts b/backend/openapi/src/tss/index.ts index 7ca982f1d..8ef92a18b 100644 --- a/backend/openapi/src/tss/index.ts +++ b/backend/openapi/src/tss/index.ts @@ -1,3 +1,4 @@ +export * from "./commit_reveal"; export * from "./keygen_ed25519"; export * from "./presign"; export * from "./request"; diff --git a/backend/openapi/src/tss/keygen_ed25519.ts b/backend/openapi/src/tss/keygen_ed25519.ts index aeacb79d4..30650cbaf 100644 --- a/backend/openapi/src/tss/keygen_ed25519.ts +++ b/backend/openapi/src/tss/keygen_ed25519.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { registry } from "../registry"; +import { CommitRevealRequestFieldsSchema } from "./commit_reveal"; const TeddsaKeygenOutputSchema = registry.register( "TeddsaKeygenOutput", @@ -22,9 +23,11 @@ const TeddsaKeygenOutputSchema = registry.register( export const KeygenEd25519RequestSchema = registry.register( "TssKeygenEd25519Request", - z.object({ - keygen_2: TeddsaKeygenOutputSchema.openapi({ - description: "Server's keygen output from centralized key generation", - }), - }), + z + .object({ + keygen_2: TeddsaKeygenOutputSchema.openapi({ + description: "Server's keygen output from centralized key generation", + }), + }) + .merge(CommitRevealRequestFieldsSchema), ); diff --git a/backend/openapi/src/tss/request.ts b/backend/openapi/src/tss/request.ts index d28f0a34f..f64e91df1 100644 --- a/backend/openapi/src/tss/request.ts +++ b/backend/openapi/src/tss/request.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { registry } from "../registry"; +import { CommitRevealRequestFieldsSchema } from "./commit_reveal"; const OAuthTypeSchema = z.enum(["google", "auth0"]).openapi({ description: "OAuth provider type", @@ -9,9 +10,11 @@ const OAuthTypeSchema = z.enum(["google", "auth0"]).openapi({ export const SignInRequestSchema = registry.register( "TssUserSignInRequest", - z.object({ - auth_type: OAuthTypeSchema, - }), + z + .object({ + auth_type: OAuthTypeSchema.optional(), + }) + .merge(CommitRevealRequestFieldsSchema), ); export const KeygenRequestSchema = registry.register( @@ -73,23 +76,25 @@ const TeddsaKeygenOutputSchema = z.object({ export const KeygenRequestV2Schema = registry.register( "TssKeygenRequestV2", - z.object({ - keygen_2_secp256k1: z - .object({ - private_share: z.string().openapi({ - description: "Private key share for secp256k1 TSS", - }), - public_key: z.string().openapi({ - description: "secp256k1 public key in hex format", + z + .object({ + keygen_2_secp256k1: z + .object({ + private_share: z.string().openapi({ + description: "Private key share for secp256k1 TSS", + }), + public_key: z.string().openapi({ + description: "secp256k1 public key in hex format", + }), + }) + .openapi({ + description: "Keygen stage 2 payload for secp256k1", }), - }) - .openapi({ - description: "Keygen stage 2 payload for secp256k1", + keygen_2_ed25519: TeddsaKeygenOutputSchema.openapi({ + description: "Server's keygen output for ed25519", }), - keygen_2_ed25519: TeddsaKeygenOutputSchema.openapi({ - description: "Server's keygen output for ed25519", - }), - }), + }) + .merge(CommitRevealRequestFieldsSchema), ); const ReshareWalletInfoSchema = z.object({ @@ -112,21 +117,23 @@ const ReshareWalletInfoSchema = z.object({ export const ReshareRequestV2Schema = registry.register( "TssUserReshareRequestV2", - z.object({ - wallets: z - .object({ - secp256k1: ReshareWalletInfoSchema.openapi({ - description: "secp256k1 wallet reshare info", - }).optional(), - ed25519: ReshareWalletInfoSchema.openapi({ - description: "ed25519 wallet reshare info", - }).optional(), - }) - .refine((data) => data.secp256k1 || data.ed25519, { - message: "At least one of secp256k1 or ed25519 must be provided", - }) - .openapi({ - description: "Wallet reshare info per curve type", - }), - }), + z + .object({ + wallets: z + .object({ + secp256k1: ReshareWalletInfoSchema.openapi({ + description: "secp256k1 wallet reshare info", + }).optional(), + ed25519: ReshareWalletInfoSchema.openapi({ + description: "ed25519 wallet reshare info", + }).optional(), + }) + .refine((data) => data.secp256k1 || data.ed25519, { + message: "At least one of secp256k1 or ed25519 must be provided", + }) + .openapi({ + description: "Wallet reshare info per curve type", + }), + }) + .merge(CommitRevealRequestFieldsSchema), ); diff --git a/common/oko_types/src/api_response/index.ts b/common/oko_types/src/api_response/index.ts index 4b4a2161c..72b56a09f 100644 --- a/common/oko_types/src/api_response/index.ts +++ b/common/oko_types/src/api_response/index.ts @@ -53,4 +53,9 @@ export type ErrorCode = | "INVALID_PUBLIC_KEY" | "INVALID_WALLET_TYPE" | "REFERRAL_NOT_FOUND" + | "SESSION_ALREADY_EXISTS" + | "SESSION_NOT_FOUND" + | "SESSION_EXPIRED" + | "API_ALREADY_CALLED" + | "INVALID_SIGNATURE" | "UNKNOWN_ERROR"; diff --git a/common/oko_types/src/commit_reveal/index.ts b/common/oko_types/src/commit_reveal/index.ts index 460526e21..8a9015342 100644 --- a/common/oko_types/src/commit_reveal/index.ts +++ b/common/oko_types/src/commit_reveal/index.ts @@ -1,5 +1,12 @@ -export type OperationType = "sign_in" | "sign_up" | "reshare"; -export type SessionState = "COMMITTED" | "COMPLETED" | "EXPIRED"; +export type OperationType = + | "sign_in" + | "sign_up" + | "sign_in_reshare" + | "add_ed25519"; + +export type ApiName = "signin" | "keygen" | "reshare" | "keygen_ed25519"; + +export type SessionState = "COMMITTED" | "COMPLETED"; export interface CommitRevealSession { session_id: string; diff --git a/key_share_node/ksn_interface/src/commit_reveal.ts b/key_share_node/ksn_interface/src/commit_reveal.ts index c0277599e..227ff1873 100644 --- a/key_share_node/ksn_interface/src/commit_reveal.ts +++ b/key_share_node/ksn_interface/src/commit_reveal.ts @@ -5,6 +5,13 @@ export type OperationType = | "register_reshare" | "add_ed25519"; +export type ApiName = + | "get_key_shares" + | "register" + | "reshare" + | "reshare_register" + | "register_ed25519"; + export type SessionState = "COMMITTED" | "COMPLETED"; export interface CommitRevealSession { diff --git a/key_share_node/server/src/commit_reveal/allowed_apis.ts b/key_share_node/server/src/commit_reveal/allowed_apis.ts index 7f0861976..1684eeb8d 100644 --- a/key_share_node/server/src/commit_reveal/allowed_apis.ts +++ b/key_share_node/server/src/commit_reveal/allowed_apis.ts @@ -1,11 +1,7 @@ -import type { OperationType } from "@oko-wallet/ksn-interface/commit_reveal"; - -export type ApiName = - | "get_key_shares" - | "register" - | "reshare" - | "reshare_register" - | "register_ed25519"; +import type { + OperationType, + ApiName, +} from "@oko-wallet/ksn-interface/commit_reveal"; export const ALLOWED_APIS = { sign_in: ["get_key_shares"], @@ -25,14 +21,14 @@ export const FINAL_APIS = { export function isApiAllowed( operationType: OperationType, - apiName: string, + apiName: ApiName, ): boolean { return ALLOWED_APIS[operationType].includes(apiName); } export function isFinalApi( operationType: OperationType, - apiName: string, + apiName: ApiName, ): boolean { return FINAL_APIS[operationType].includes(apiName); } diff --git a/key_share_node/server/src/middlewares/commit_reveal.test.ts b/key_share_node/server/src/middlewares/commit_reveal.test.ts index bf429b5df..d4ddfb6c0 100644 --- a/key_share_node/server/src/middlewares/commit_reveal.test.ts +++ b/key_share_node/server/src/middlewares/commit_reveal.test.ts @@ -10,11 +10,6 @@ import { convertEddsaSignatureToBytes, } from "@oko-wallet/crypto-js/node/ecdhe"; import { sha256 } from "@oko-wallet/crypto-js"; - -import { connectPG, resetPgDatabase } from "@oko-wallet-ksn-server/database"; -import { testPgConfig } from "@oko-wallet-ksn-server/database/test_config"; -import { commitRevealMiddleware } from "./commit_reveal"; -import type { ServerState } from "@oko-wallet-ksn-server/state"; import type { OperationType } from "@oko-wallet/ksn-interface/commit_reveal"; import { createCommitRevealSession, @@ -22,6 +17,11 @@ import { hasCommitRevealApiBeenCalled, } from "@oko-wallet/ksn-pg-interface/commit_reveal"; +import { connectPG, resetPgDatabase } from "@oko-wallet-ksn-server/database"; +import { testPgConfig } from "@oko-wallet-ksn-server/database/test_config"; +import { commitRevealMiddleware } from "./commit_reveal"; +import type { ServerState } from "@oko-wallet-ksn-server/state"; + // Mock server keypair const serverPrivateKeyRes = Bytes.fromHexString( "0000000000000000000000000000000000000000000000000000000000000001", diff --git a/key_share_node/server/src/middlewares/commit_reveal.ts b/key_share_node/server/src/middlewares/commit_reveal.ts index 01fc66c21..b3b379e42 100644 --- a/key_share_node/server/src/middlewares/commit_reveal.ts +++ b/key_share_node/server/src/middlewares/commit_reveal.ts @@ -8,16 +8,18 @@ import { updateCommitRevealSessionState, hasCommitRevealApiBeenCalled, } from "@oko-wallet/ksn-pg-interface/commit_reveal"; +import type { + ApiName, + CommitRevealSession, +} from "@oko-wallet/ksn-interface/commit_reveal"; import { ErrorCodeMap } from "@oko-wallet-ksn-server/error"; -import { - isApiAllowed, - isFinalApi, - type ApiName, -} from "@oko-wallet-ksn-server/commit_reveal"; +import { isApiAllowed, isFinalApi } from "@oko-wallet-ksn-server/commit_reveal"; import type { ServerState } from "@oko-wallet-ksn-server/state"; import { logger } from "@oko-wallet-ksn-server/logger"; +const DEFAULT_AUTH_TYPE = "google"; + export interface CommitRevealBody { cr_session_id: string; cr_signature: string; // 128 chars hex (64 bytes) @@ -39,7 +41,6 @@ export function commitRevealMiddleware(apiName: ApiName) { return; } - // Get session from DB const sessionResult = await getCommitRevealSessionBySessionId( state.db, cr_session_id, @@ -137,7 +138,7 @@ export function commitRevealMiddleware(apiName: ApiName) { } // Get auth_type and id_token from request - const authType = body.auth_type ?? "google"; + const authType = body.auth_type ?? DEFAULT_AUTH_TYPE; const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith("Bearer ")) { res.status(401).json({ @@ -169,9 +170,15 @@ export function commitRevealMiddleware(apiName: ApiName) { return; } - // Verify signature: message = node_pubkey + session_id + auth_type + id_token + operation_type + api_name const nodePubkeyHex = state.serverKeypair.publicKey.toHex(); - const message = `${nodePubkeyHex}${cr_session_id}${authType}${idToken}${session.operation_type}${apiName}`; + const message = makeSigMessage({ + nodePubkeyHex, + cr_session_id, + authType, + idToken, + session, + apiName, + }); const rBytes = Bytes.fromUint8Array( signatureRes.data.toUint8Array().slice(0, 32), 32, @@ -252,3 +259,31 @@ export function commitRevealMiddleware(apiName: ApiName) { next(); }; } + +export interface SigMessageArgs { + nodePubkeyHex: string; + cr_session_id: string; + authType: string; + idToken: string; + session: CommitRevealSession; + apiName: ApiName; +} + +// message = node_pubkey + session_id + auth_type + id_token + operation_type + api_name +function makeSigMessage({ + nodePubkeyHex, + cr_session_id, + authType, + idToken, + session, + apiName, +}: SigMessageArgs) { + return ( + nodePubkeyHex + + cr_session_id + + authType + + idToken + + session.operation_type + + apiName + ); +} diff --git a/key_share_node/server/src/routes/key_share_v2/commit.test.ts b/key_share_node/server/src/routes/key_share_v2/commit.test.ts index f066e022d..bd7b502b8 100644 --- a/key_share_node/server/src/routes/key_share_v2/commit.test.ts +++ b/key_share_node/server/src/routes/key_share_v2/commit.test.ts @@ -32,7 +32,7 @@ function generateRandomHex(bytes: number): string { return randomBytes(bytes).toString("hex"); } -describe("commit_reveal_commit_test", () => { +describe("commit_route_test", () => { let pool: Pool; let app: express.Application; diff --git a/key_share_node/server/src/routes/key_share_v2/e2e.test.ts b/key_share_node/server/src/routes/key_share_v2/e2e.test.ts index 468ac584b..bb080e905 100644 --- a/key_share_node/server/src/routes/key_share_v2/e2e.test.ts +++ b/key_share_node/server/src/routes/key_share_v2/e2e.test.ts @@ -1,11 +1,3 @@ -/** - * E2E Integration Tests for Commit-Reveal + KeyShare v2 APIs - * - * Tests the full flow: - * 1. Commit phase - POST /keyshare/v2/commit - * 2. Reveal + API call - POST /keyshare/v2/xxx with commit-reveal signature - * 3. Verify data persistence and session state updates - */ import request from "supertest"; import express from "express"; import { Pool } from "pg"; @@ -18,12 +10,12 @@ import { convertEddsaSignatureToBytes, } from "@oko-wallet/crypto-js/node/ecdhe"; import { sha256 } from "@oko-wallet/crypto-js"; +import type { OperationType } from "@oko-wallet/ksn-interface/commit_reveal"; +import { getCommitRevealSessionBySessionId } from "@oko-wallet/ksn-pg-interface/commit_reveal"; import { connectPG, resetPgDatabase } from "@oko-wallet-ksn-server/database"; import { testPgConfig } from "@oko-wallet-ksn-server/database/test_config"; import type { ServerState } from "@oko-wallet-ksn-server/state"; -import type { OperationType } from "@oko-wallet/ksn-interface/commit_reveal"; -import { getCommitRevealSessionBySessionId } from "@oko-wallet/ksn-pg-interface/commit_reveal"; import { checkKeyShareV2 } from "@oko-wallet-ksn-server/api/key_share"; import { commitRevealMiddleware } from "@oko-wallet-ksn-server/middlewares"; import { keyshareV2Register } from "./register"; @@ -149,7 +141,7 @@ function mockOAuthMiddleware( next(); } -describe("e2e_commit_reveal_keyshare_test", () => { +describe("key_share_v2_commit_reveal_e2e_test", () => { let pool: Pool; let app: express.Application;