From a7633403e2e463606c7bcf168ea557529479479d Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 28 Jan 2026 18:01:38 +0900 Subject: [PATCH 01/25] merge OKO-574 --- .../ksn_interface/src/commit_reveal.ts | 9 +- key_share_node/ksn_interface/src/response.ts | 6 +- key_share_node/pg_interface/package.json | 3 +- .../pg_interface/src/bin/migrate/migrate.sql | 41 + .../server/src/api/key_share/v2.test.ts | 186 +-- .../server/src/commit_reveal/allowed_apis.ts | 31 + .../server/src/commit_reveal/index.ts | 1 + key_share_node/server/src/error/index.ts | 6 +- .../src/middlewares/commit_reveal.test.ts | 1179 +++++++++++++++++ .../server/src/middlewares/commit_reveal.ts | 250 ++++ .../server/src/middlewares/index.ts | 1 + key_share_node/server/src/openapi/doc.ts | 6 +- .../src/openapi/schema/commit_reveal.ts | 113 ++ .../server/src/openapi/schema/index.ts | 1 + .../server/src/openapi/schema/key_share_v2.ts | 17 +- .../src/routes/commit_reveal/commit.test.ts | 427 ++++++ .../server/src/routes/commit_reveal/commit.ts | 174 +++ .../server/src/routes/commit_reveal/index.ts | 11 + key_share_node/server/src/routes/index.ts | 4 + .../src/routes/key_share_v2/e2e.test.ts | 989 ++++++++++++++ .../server/src/routes/key_share_v2/ed25519.ts | 67 +- .../src/routes/key_share_v2/get_key_shares.ts | 61 +- .../server/src/routes/key_share_v2/index.ts | 28 +- .../src/routes/key_share_v2/register.ts | 58 +- .../server/src/routes/key_share_v2/reshare.ts | 61 +- .../routes/key_share_v2/reshare_register.ts | 73 +- 26 files changed, 3685 insertions(+), 118 deletions(-) create mode 100644 key_share_node/server/src/commit_reveal/allowed_apis.ts create mode 100644 key_share_node/server/src/commit_reveal/index.ts create mode 100644 key_share_node/server/src/middlewares/commit_reveal.test.ts create mode 100644 key_share_node/server/src/middlewares/commit_reveal.ts create mode 100644 key_share_node/server/src/openapi/schema/commit_reveal.ts create mode 100644 key_share_node/server/src/routes/commit_reveal/commit.test.ts create mode 100644 key_share_node/server/src/routes/commit_reveal/commit.ts create mode 100644 key_share_node/server/src/routes/commit_reveal/index.ts create mode 100644 key_share_node/server/src/routes/key_share_v2/e2e.test.ts diff --git a/key_share_node/ksn_interface/src/commit_reveal.ts b/key_share_node/ksn_interface/src/commit_reveal.ts index 460526e21..fbadd3c4d 100644 --- a/key_share_node/ksn_interface/src/commit_reveal.ts +++ b/key_share_node/ksn_interface/src/commit_reveal.ts @@ -1,5 +1,10 @@ -export type OperationType = "sign_in" | "sign_up" | "reshare"; -export type SessionState = "COMMITTED" | "COMPLETED" | "EXPIRED"; +export type OperationType = + | "sign_in" + | "sign_up" + | "sign_in_reshare" + | "register_reshare" + | "add_ed25519"; +export type SessionState = "COMMITTED" | "COMPLETED"; export interface CommitRevealSession { session_id: string; diff --git a/key_share_node/ksn_interface/src/response.ts b/key_share_node/ksn_interface/src/response.ts index 3e0117065..d1fed35b2 100644 --- a/key_share_node/ksn_interface/src/response.ts +++ b/key_share_node/ksn_interface/src/response.ts @@ -33,4 +33,8 @@ export type KSNodeApiErrorCode = | "RATE_LIMIT_EXCEEDED" | "CURVE_TYPE_NOT_SUPPORTED" | "RESHARE_FAILED" - | "USER_ALREADY_REGISTERED"; + | "SESSION_ALREADY_EXISTS" + | "SESSION_NOT_FOUND" + | "SESSION_EXPIRED" + | "INVALID_SIGNATURE" + | "API_ALREADY_CALLED"; diff --git a/key_share_node/pg_interface/package.json b/key_share_node/pg_interface/package.json index f78df042e..3f9b2284d 100644 --- a/key_share_node/pg_interface/package.json +++ b/key_share_node/pg_interface/package.json @@ -5,7 +5,8 @@ "main": "./src/index.ts", "exports": { ".": "./src/index.ts", - "./knexfile": "./knexfile.ts" + "./knexfile": "./knexfile.ts", + "./commit_reveal": "./src/commit_reveal/index.ts" }, "scripts": { "migrate": "MIGRATE_MODE=all NODE_COUNT=3 tsx ./src/bin/migrate/index.ts", diff --git a/key_share_node/pg_interface/src/bin/migrate/migrate.sql b/key_share_node/pg_interface/src/bin/migrate/migrate.sql index 669ca90b3..a187f356c 100644 --- a/key_share_node/pg_interface/src/bin/migrate/migrate.sql +++ b/key_share_node/pg_interface/src/bin/migrate/migrate.sql @@ -92,3 +92,44 @@ CREATE TABLE public."2_server_keypairs" ( CONSTRAINT "2_server_keypairs_version_key" UNIQUE (version) ); CREATE INDEX idx_2_server_keypairs_is_active ON public."2_server_keypairs" USING btree (is_active) WHERE (is_active = true); + + +-- public.2_commit_reveal_sessions definition + +-- Drop table + +-- DROP TABLE public."2_commit_reveal_sessions"; + +CREATE TABLE public."2_commit_reveal_sessions" ( + session_id uuid NOT NULL, + operation_type varchar(32) NOT NULL, + client_ephemeral_pubkey bytea NOT NULL, + id_token_hash varchar(64) NOT NULL, + state varchar(16) DEFAULT 'COMMITTED'::character varying NOT NULL, + created_at timestamptz DEFAULT now() NOT NULL, + expires_at timestamptz NOT NULL, + CONSTRAINT "2_commit_reveal_sessions_pkey" PRIMARY KEY (session_id), + CONSTRAINT "2_cr_sessions_client_pubkey_key" UNIQUE (client_ephemeral_pubkey), + CONSTRAINT "2_cr_sessions_id_token_hash_key" UNIQUE (id_token_hash) +); +CREATE INDEX idx_2_cr_sessions_state ON public."2_commit_reveal_sessions" USING btree (state); +CREATE INDEX idx_2_cr_sessions_expires_at ON public."2_commit_reveal_sessions" USING btree (expires_at); + + +-- public.2_commit_reveal_api_calls definition + +-- Drop table + +-- DROP TABLE public."2_commit_reveal_api_calls"; + +CREATE TABLE public."2_commit_reveal_api_calls" ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + session_id uuid NOT NULL, + api_name varchar(64) NOT NULL, + signature bytea NOT NULL, + called_at timestamptz DEFAULT now() NOT NULL, + CONSTRAINT "2_commit_reveal_api_calls_pkey" PRIMARY KEY (id), + CONSTRAINT "2_cr_api_calls_signature_key" UNIQUE (signature), + CONSTRAINT "2_cr_api_calls_session_api_key" UNIQUE (session_id, api_name) +); +CREATE INDEX idx_2_cr_api_calls_session_id ON public."2_commit_reveal_api_calls" USING btree (session_id); diff --git a/key_share_node/server/src/api/key_share/v2.test.ts b/key_share_node/server/src/api/key_share/v2.test.ts index c493d8d6c..0e2a79ae8 100644 --- a/key_share_node/server/src/api/key_share/v2.test.ts +++ b/key_share_node/server/src/api/key_share/v2.test.ts @@ -21,6 +21,7 @@ import { reshareKeyShareV2, reshareRegisterV2, } from "@oko-wallet-ksn-server/api/key_share"; +import { encryptDataAsync } from "@oko-wallet-ksn-server/encrypt"; const TEST_ENC_SECRET = "test_enc_secret"; @@ -112,7 +113,7 @@ describe("key_share_v2_test", () => { expect(result.success).toBe(true); }); - it("3.2 success - new user secp256k1 only", async () => { + it("3.2 failure - INVALID_REQUEST (missing ed25519)", async () => { const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); const secp256k1Share = generateRandomShare(); @@ -128,12 +129,17 @@ describe("key_share_v2_test", () => { TEST_ENC_SECRET, ); - expect(result.success).toBe(true); + expect(result.success).toBe(false); + if (result.success === false) { + expect(result.code).toBe("INVALID_REQUEST"); + } }); it("3.3 failure - DUPLICATE_PUBLIC_KEY", async () => { const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); + const ed25519Pk = parseEd25519PublicKey(TEST_ED25519_PK); const secp256k1Share = generateRandomShare(); + const ed25519Share = generateRandomShare(); // Pre-create wallet with same public key await createWallet(pool, { @@ -149,6 +155,7 @@ describe("key_share_v2_test", () => { auth_type: "google", wallets: { secp256k1: { public_key: secp256k1Pk, share: secp256k1Share }, + ed25519: { public_key: ed25519Pk, share: ed25519Share }, }, }, TEST_ENC_SECRET, @@ -160,42 +167,6 @@ describe("key_share_v2_test", () => { } }); - it("3.4 failure - USER_ALREADY_REGISTERED", async () => { - const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); - const secp256k1Pk2 = parseSecp256k1PublicKey(TEST_SECP256K1_PK_2); - const secp256k1Share = generateRandomShare(); - - // First registration - await registerKeyShareV2( - pool, - { - user_auth_id: TEST_USER_AUTH_ID, - auth_type: "google", - wallets: { - secp256k1: { public_key: secp256k1Pk, share: secp256k1Share }, - }, - }, - TEST_ENC_SECRET, - ); - - // Second registration attempt with different pk - const result = await registerKeyShareV2( - pool, - { - user_auth_id: TEST_USER_AUTH_ID, - auth_type: "google", - wallets: { - secp256k1: { public_key: secp256k1Pk2, share: secp256k1Share }, - }, - }, - TEST_ENC_SECRET, - ); - - expect(result.success).toBe(false); - if (result.success === false) { - expect(result.code).toBe("USER_ALREADY_REGISTERED"); - } - }); }); // ============================================================================ @@ -238,23 +209,30 @@ describe("key_share_v2_test", () => { } }); - it("2.2 success - partial exists (secp256k1 only)", async () => { + it("2.2 success - partial exists (secp256k1 only, legacy user)", async () => { const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); const ed25519Pk = parseEd25519PublicKey(TEST_ED25519_PK); const secp256k1Share = generateRandomShare(); - // Register secp256k1 only - await registerKeyShareV2( - pool, - { - user_auth_id: TEST_USER_AUTH_ID, - auth_type: "google", - wallets: { - secp256k1: { public_key: secp256k1Pk, share: secp256k1Share }, - }, - }, - TEST_ENC_SECRET, - ); + // Create user with only secp256k1 using low-level functions (simulating legacy user) + const createUserRes = await createUser(pool, "google", TEST_USER_AUTH_ID); + if (createUserRes.success === false) { + throw new Error("Failed to create user"); + } + + const walletRes = await createWallet(pool, { + user_id: createUserRes.data.user_id, + curve_type: "secp256k1", + public_key: secp256k1Pk.toUint8Array(), + }); + if (walletRes.success === false) { + throw new Error("Failed to create wallet"); + } + + await createKeyShare(pool, { + wallet_id: walletRes.data.wallet_id, + enc_share: Buffer.from(secp256k1Share.toUint8Array()), + }); const result = await checkKeyShareV2(pool, { user_auth_id: TEST_USER_AUTH_ID, @@ -356,22 +334,36 @@ describe("key_share_v2_test", () => { // getKeyShareV2 // ============================================================================ describe("getKeyShareV2", () => { - it("1.1 success - secp256k1 only", async () => { + it("1.1 success - secp256k1 only (legacy user)", async () => { const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); const secp256k1Share = generateRandomShare(); - await registerKeyShareV2( - pool, - { - user_auth_id: TEST_USER_AUTH_ID, - auth_type: "google", - wallets: { - secp256k1: { public_key: secp256k1Pk, share: secp256k1Share }, - }, - }, + // Create legacy user with only secp256k1 using low-level functions + const createUserRes = await createUser(pool, "google", TEST_USER_AUTH_ID); + if (createUserRes.success === false) { + throw new Error("Failed to create user"); + } + + const walletRes = await createWallet(pool, { + user_id: createUserRes.data.user_id, + curve_type: "secp256k1", + public_key: secp256k1Pk.toUint8Array(), + }); + if (walletRes.success === false) { + throw new Error("Failed to create wallet"); + } + + // Encrypt the share before storing (same as registerKeyShareV2 does) + const encryptedShare = await encryptDataAsync( + secp256k1Share.toHex(), TEST_ENC_SECRET, ); + await createKeyShare(pool, { + wallet_id: walletRes.data.wallet_id, + enc_share: Buffer.from(encryptedShare, "utf8"), + }); + const result = await getKeyShareV2( pool, { @@ -493,9 +485,11 @@ describe("key_share_v2_test", () => { it("1.5 failure - WALLET_NOT_FOUND", async () => { const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); const secp256k1Pk2 = parseSecp256k1PublicKey(TEST_SECP256K1_PK_2); + const ed25519Pk = parseEd25519PublicKey(TEST_ED25519_PK); const secp256k1Share = generateRandomShare(); + const ed25519Share = generateRandomShare(); - // Register with different pk + // Register with different secp256k1 pk (but include ed25519 for valid registration) await registerKeyShareV2( pool, { @@ -503,11 +497,13 @@ describe("key_share_v2_test", () => { auth_type: "google", wallets: { secp256k1: { public_key: secp256k1Pk2, share: secp256k1Share }, + ed25519: { public_key: ed25519Pk, share: ed25519Share }, }, }, TEST_ENC_SECRET, ); + // Try to get with non-existent secp256k1 pk const result = await getKeyShareV2( pool, { @@ -593,24 +589,31 @@ describe("key_share_v2_test", () => { // registerEd25519V2 // ============================================================================ describe("registerEd25519V2", () => { - it("4.1 success - add ed25519 to user with secp256k1", async () => { + it("4.1 success - add ed25519 to user with secp256k1 (legacy user)", async () => { const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); const ed25519Pk = parseEd25519PublicKey(TEST_ED25519_PK); const secp256k1Share = generateRandomShare(); const ed25519Share = generateRandomShare(); - // First register secp256k1 - await registerKeyShareV2( - pool, - { - user_auth_id: TEST_USER_AUTH_ID, - auth_type: "google", - wallets: { - secp256k1: { public_key: secp256k1Pk, share: secp256k1Share }, - }, - }, - TEST_ENC_SECRET, - ); + // Create legacy user with only secp256k1 using low-level functions + const createUserRes = await createUser(pool, "google", TEST_USER_AUTH_ID); + if (createUserRes.success === false) { + throw new Error("Failed to create user"); + } + + const walletRes = await createWallet(pool, { + user_id: createUserRes.data.user_id, + curve_type: "secp256k1", + public_key: secp256k1Pk.toUint8Array(), + }); + if (walletRes.success === false) { + throw new Error("Failed to create wallet"); + } + + await createKeyShare(pool, { + wallet_id: walletRes.data.wallet_id, + enc_share: Buffer.from(secp256k1Share.toUint8Array()), + }); // Then add ed25519 const result = await registerEd25519V2( @@ -865,9 +868,11 @@ describe("key_share_v2_test", () => { it("5.5 failure - WALLET_NOT_FOUND", async () => { const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); const secp256k1Pk2 = parseSecp256k1PublicKey(TEST_SECP256K1_PK_2); + const ed25519Pk = parseEd25519PublicKey(TEST_ED25519_PK); const secp256k1Share = generateRandomShare(); + const ed25519Share = generateRandomShare(); - // Register with different pk + // Register with different secp256k1 pk (but include ed25519 for valid registration) await registerKeyShareV2( pool, { @@ -875,12 +880,13 @@ describe("key_share_v2_test", () => { auth_type: "google", wallets: { secp256k1: { public_key: secp256k1Pk2, share: secp256k1Share }, + ed25519: { public_key: ed25519Pk, share: ed25519Share }, }, }, TEST_ENC_SECRET, ); - // Try to reshare with non-existent pk + // Try to reshare with non-existent secp256k1 pk const result = await reshareKeyShareV2( pool, { @@ -935,10 +941,12 @@ describe("key_share_v2_test", () => { it("5.7 failure - RESHARE_FAILED (wrong share value)", async () => { const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); + const ed25519Pk = parseEd25519PublicKey(TEST_ED25519_PK); const secp256k1Share = generateRandomShare(); + const ed25519Share = generateRandomShare(); const wrongShare = generateRandomShare(); - // Register + // Register with both wallets await registerKeyShareV2( pool, { @@ -946,6 +954,7 @@ describe("key_share_v2_test", () => { auth_type: "google", wallets: { secp256k1: { public_key: secp256k1Pk, share: secp256k1Share }, + ed25519: { public_key: ed25519Pk, share: ed25519Share }, }, }, TEST_ENC_SECRET, @@ -1063,7 +1072,9 @@ describe("key_share_v2_test", () => { expect(result.success).toBe(true); }); - it("6.4 failure - USER_NOT_FOUND", async () => { + it("6.4 success - creates new user if not exists (reshare scenario)", async () => { + // reshareRegisterV2 is designed to create users that don't exist locally + // This is for reshare to new nodes where user exists on other nodes const secp256k1Pk = parseSecp256k1PublicKey(TEST_SECP256K1_PK); const secp256k1Share = generateRandomShare(); @@ -1079,9 +1090,20 @@ describe("key_share_v2_test", () => { TEST_ENC_SECRET, ); - expect(result.success).toBe(false); - if (result.success === false) { - expect(result.code).toBe("USER_NOT_FOUND"); + expect(result.success).toBe(true); + + // Verify the user and wallet were created + const checkResult = await checkKeyShareV2(pool, { + user_auth_id: TEST_USER_AUTH_ID_NONEXISTENT, + auth_type: "google", + wallets: { + secp256k1: secp256k1Pk, + }, + }); + + expect(checkResult.success).toBe(true); + if (checkResult.success) { + expect(checkResult.data.secp256k1?.exists).toBe(true); } }); diff --git a/key_share_node/server/src/commit_reveal/allowed_apis.ts b/key_share_node/server/src/commit_reveal/allowed_apis.ts new file mode 100644 index 000000000..c161b20a0 --- /dev/null +++ b/key_share_node/server/src/commit_reveal/allowed_apis.ts @@ -0,0 +1,31 @@ +import type { OperationType } from "@oko-wallet/ksn-interface/commit_reveal"; + +export const ALLOWED_APIS: Record = { + sign_in: ["get_key_shares"], + sign_up: ["register"], + sign_in_reshare: ["get_key_shares", "reshare"], + register_reshare: ["get_key_shares", "reshare_register"], + add_ed25519: ["register_ed25519", "get_key_shares"], +}; + +export const FINAL_APIS: Record = { + sign_in: ["get_key_shares"], + sign_up: ["register"], + sign_in_reshare: ["reshare"], + register_reshare: ["reshare_register"], + add_ed25519: ["get_key_shares"], +}; + +export function isApiAllowed( + operationType: OperationType, + apiName: string, +): boolean { + return ALLOWED_APIS[operationType].includes(apiName); +} + +export function isFinalApi( + operationType: OperationType, + apiName: string, +): boolean { + return FINAL_APIS[operationType].includes(apiName); +} diff --git a/key_share_node/server/src/commit_reveal/index.ts b/key_share_node/server/src/commit_reveal/index.ts new file mode 100644 index 000000000..ccea4f78d --- /dev/null +++ b/key_share_node/server/src/commit_reveal/index.ts @@ -0,0 +1 @@ +export * from "./allowed_apis"; diff --git a/key_share_node/server/src/error/index.ts b/key_share_node/server/src/error/index.ts index 6b2156978..a40ecb5f2 100644 --- a/key_share_node/server/src/error/index.ts +++ b/key_share_node/server/src/error/index.ts @@ -20,5 +20,9 @@ export const ErrorCodeMap: Record = { RATE_LIMIT_EXCEEDED: 429, CURVE_TYPE_NOT_SUPPORTED: 400, RESHARE_FAILED: 500, - USER_ALREADY_REGISTERED: 409, + SESSION_ALREADY_EXISTS: 409, + SESSION_NOT_FOUND: 404, + SESSION_EXPIRED: 410, + INVALID_SIGNATURE: 400, + API_ALREADY_CALLED: 409, }; diff --git a/key_share_node/server/src/middlewares/commit_reveal.test.ts b/key_share_node/server/src/middlewares/commit_reveal.test.ts new file mode 100644 index 000000000..f0e75f1b4 --- /dev/null +++ b/key_share_node/server/src/middlewares/commit_reveal.test.ts @@ -0,0 +1,1179 @@ +import request from "supertest"; +import express from "express"; +import { Pool } from "pg"; +import dayjs from "dayjs"; +import { Bytes } from "@oko-wallet/bytes"; +import { v4 as uuidv4 } from "uuid"; +import { + generateEddsaKeypair, + signMessage, + 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, + getCommitRevealSessionBySessionId, + hasCommitRevealApiBeenCalled, +} from "@oko-wallet/ksn-pg-interface/commit_reveal"; + +// Mock server keypair +const serverPrivateKeyRes = Bytes.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000001", + 32, +); +const serverPublicKeyRes = Bytes.fromHexString( + "4cb5abf6ad79fbf5abbccafcc269d85cd2651ed4b885b5869f241aedf0a5ba29", + 32, +); +if (!serverPrivateKeyRes.success || !serverPublicKeyRes.success) { + throw new Error("Failed to create mock server keypair"); +} +const mockServerKeypair = { + privateKey: serverPrivateKeyRes.data, + publicKey: serverPublicKeyRes.data, +}; + +interface TestContext { + sessionId: string; + clientKeypair: { + privateKey: Bytes<32>; + publicKey: Bytes<32>; + }; + idToken: string; + authType: string; + operationType: OperationType; + apiName: string; +} + +function createTestContext( + overrides: Partial = {}, +): TestContext { + const keypairRes = generateEddsaKeypair(); + if (!keypairRes.success) { + throw new Error(`Failed to generate keypair: ${keypairRes.err}`); + } + + return { + sessionId: uuidv4(), + clientKeypair: keypairRes.data, + idToken: `test_id_token_${Date.now()}`, + authType: "google", + operationType: "sign_in", + apiName: "get_key_shares", + ...overrides, + }; +} + +function computeIdTokenHash(authType: string, idToken: string): string { + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) { + throw new Error(`Failed to compute hash: ${hashRes.err}`); + } + return hashRes.data.toHex(); +} + +function createRevealSignature( + ctx: TestContext, + nodePubkeyHex: string, +): string { + const message = `${nodePubkeyHex}${ctx.sessionId}${ctx.authType}${ctx.idToken}${ctx.operationType}${ctx.apiName}`; + const signRes = signMessage(message, ctx.clientKeypair.privateKey); + if (!signRes.success) { + throw new Error(`Failed to sign message: ${signRes.err}`); + } + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) { + throw new Error(`Failed to convert signature: ${sigBytesRes.err}`); + } + return sigBytesRes.data.toHex(); +} + +async function createSession( + pool: Pool, + ctx: TestContext, + options: { expiresInMs?: number; state?: string } = {}, +) { + const idTokenHash = computeIdTokenHash(ctx.authType, ctx.idToken); + const expiresAt = new Date(Date.now() + (options.expiresInMs ?? 5 * 60 * 1000)); + + const result = await createCommitRevealSession(pool, { + session_id: ctx.sessionId, + operation_type: ctx.operationType, + client_ephemeral_pubkey: ctx.clientKeypair.publicKey.toUint8Array(), + id_token_hash: idTokenHash, + expires_at: expiresAt, + }); + + if (!result.success) { + throw new Error(`Failed to create session: ${result.err}`); + } + + // Update state if needed + if (options.state && options.state !== "COMMITTED") { + await pool.query( + 'UPDATE "2_commit_reveal_sessions" SET state = $1 WHERE session_id = $2', + [options.state, ctx.sessionId], + ); + } + + return result.data; +} + +describe("commit_reveal_middleware_test", () => { + let pool: Pool; + let app: express.Application; + + beforeAll(async () => { + const config = testPgConfig; + const createPostgresRes = await connectPG({ + 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; + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + + // Create fresh app for each test + app = express(); + app.use(express.json()); + + app.locals = { + db: pool, + encryptionSecret: "temp_enc_secret", + serverKeypair: mockServerKeypair, + telegram_bot_token: "temp_telegram_bot_token", + is_db_backup_checked: false, + launch_time: dayjs().toISOString(), + git_hash: "", + version: "", + } satisfies ServerState; + + // Add test route with middleware + app.post( + "/test/get_key_shares", + commitRevealMiddleware("get_key_shares"), + (_req, res) => { + res.status(200).json({ success: true, data: { message: "ok" } }); + }, + ); + + app.post( + "/test/register", + commitRevealMiddleware("register"), + (_req, res) => { + res.status(200).json({ success: true, data: { message: "ok" } }); + }, + ); + + app.post( + "/test/reshare", + commitRevealMiddleware("reshare"), + (_req, res) => { + res.status(200).json({ success: true, data: { message: "ok" } }); + }, + ); + + app.post( + "/test/reshare_register", + commitRevealMiddleware("reshare_register"), + (_req, res) => { + res.status(200).json({ success: true, data: { message: "ok" } }); + }, + ); + + app.post( + "/test/register_ed25519", + commitRevealMiddleware("register_ed25519"), + (_req, res) => { + res.status(200).json({ success: true, data: { message: "ok" } }); + }, + ); + + // Route that returns 500 to test failure behavior + app.post( + "/test/get_key_shares_fail", + commitRevealMiddleware("get_key_shares"), + (_req, res) => { + res.status(500).json({ success: false, code: "SERVER_ERROR", msg: "Simulated failure" }); + }, + ); + }); + + afterAll(async () => { + await pool.end(); + }); + + describe("success cases", () => { + it("should pass middleware with valid signature for sign_in operation", async () => { + const ctx = createTestContext({ + operationType: "sign_in", + apiName: "get_key_shares", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.message).toBe("ok"); + }); + + it("should pass middleware with valid signature for sign_up operation", async () => { + const ctx = createTestContext({ + operationType: "sign_up", + apiName: "register", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + it("should pass middleware with valid signature for sign_in_reshare operation", async () => { + const ctx = createTestContext({ + operationType: "sign_in_reshare", + apiName: "reshare", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/reshare") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + it("should pass middleware with valid signature for register_reshare operation", async () => { + const ctx = createTestContext({ + operationType: "register_reshare", + apiName: "reshare_register", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/reshare_register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + it("should pass middleware with valid signature for add_ed25519 operation", async () => { + const ctx = createTestContext({ + operationType: "add_ed25519", + apiName: "register_ed25519", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/register_ed25519") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + it("should default auth_type to google if not provided", async () => { + const ctx = createTestContext({ + operationType: "sign_in", + apiName: "get_key_shares", + authType: "google", // Will be used for hash but not sent in request + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + // auth_type not provided, should default to "google" + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + }); + + describe("missing required fields", () => { + it("should return 400 when cr_session_id is missing", async () => { + const ctx = createTestContext(); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_signature: signature, + auth_type: ctx.authType, + }) + .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 ctx = createTestContext(); + await createSession(pool, ctx); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + auth_type: ctx.authType, + }) + .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 ctx = createTestContext(); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .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 ctx = createTestContext(); + // Don't create session + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .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 ctx = createTestContext(); + await createSession(pool, ctx, { state: "REVEALED" }); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .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 ctx = createTestContext(); + await createSession(pool, ctx, { expiresInMs: -1000 }); // Expired 1 second ago + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .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 ctx = createTestContext({ + operationType: "sign_in", // sign_in only allows get_key_shares + apiName: "register", // register is for sign_up + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/register") // Trying to call register with sign_in operation + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("not allowed"); + }); + + it("should allow get_key_shares for sign_in_reshare operation", async () => { + const ctx = createTestContext({ + operationType: "sign_in_reshare", + apiName: "get_key_shares", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + it("should allow get_key_shares for register_reshare operation", async () => { + const ctx = createTestContext({ + operationType: "register_reshare", + apiName: "get_key_shares", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + + it("should allow register_ed25519 for add_ed25519 operation", async () => { + const ctx = createTestContext({ + operationType: "add_ed25519", + apiName: "register_ed25519", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/register_ed25519") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + }); + + describe("id_token_hash validation", () => { + it("should return 400 when id_token does not match committed hash", async () => { + const ctx = createTestContext(); + await createSession(pool, ctx); + + // Create signature with correct token but send different token + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", "Bearer wrong_token") // Different token + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.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 ctx = createTestContext({ authType: "google" }); + await createSession(pool, ctx); + + // Create signature with google auth_type + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: "apple", // Different auth_type + }) + .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", async () => { + const ctx = createTestContext(); + await createSession(pool, ctx); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: "invalid_hex", + auth_type: ctx.authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + + it("should return 400 when signature is wrong length", async () => { + const ctx = createTestContext(); + await createSession(pool, ctx); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: "abcd1234", // Too short + auth_type: ctx.authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + + it("should return 400 when signature is invalid (wrong message)", async () => { + const ctx = createTestContext(); + await createSession(pool, ctx); + + // Sign with wrong message + const wrongMessage = "wrong_message"; + const signRes = signMessage(wrongMessage, ctx.clientKeypair.privateKey); + if (!signRes.success) { + throw new Error(`Failed to sign: ${signRes.err}`); + } + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) { + throw new Error(`Failed to convert: ${sigBytesRes.err}`); + } + const wrongSignature = sigBytesRes.data.toHex(); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: wrongSignature, + auth_type: ctx.authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + + it("should return 400 when signature is from different keypair", async () => { + const ctx = createTestContext(); + await createSession(pool, ctx); + + // Generate different keypair and sign + const otherKeypairRes = generateEddsaKeypair(); + if (!otherKeypairRes.success) { + throw new Error("Failed to generate other keypair"); + } + + const message = `${mockServerKeypair.publicKey.toHex()}${ctx.sessionId}${ctx.authType}${ctx.idToken}${ctx.operationType}${ctx.apiName}`; + const signRes = signMessage(message, otherKeypairRes.data.privateKey); + if (!signRes.success) { + throw new Error(`Failed to sign: ${signRes.err}`); + } + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) { + throw new Error(`Failed to convert: ${sigBytesRes.err}`); + } + const wrongSignature = sigBytesRes.data.toHex(); + + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: wrongSignature, + auth_type: ctx.authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + }); + + describe("replay attack prevention", () => { + it("should record api_call on successful response", async () => { + const ctx = createTestContext(); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify api_call was recorded + const hasBeenCalled = await hasCommitRevealApiBeenCalled( + pool, + ctx.sessionId, + ctx.apiName, + ); + if (!hasBeenCalled.success) { + throw new Error(`Failed to check api call: ${hasBeenCalled.err}`); + } + expect(hasBeenCalled.data).toBe(true); + }); + + it("should NOT record api_call on failed response (allows retry)", async () => { + const ctx = createTestContext(); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + await request(app) + .post("/test/get_key_shares_fail") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(500); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify api_call was NOT recorded (allows retry) + const hasBeenCalled = await hasCommitRevealApiBeenCalled( + pool, + ctx.sessionId, + ctx.apiName, + ); + if (!hasBeenCalled.success) { + throw new Error(`Failed to check api call: ${hasBeenCalled.err}`); + } + expect(hasBeenCalled.data).toBe(false); + }); + + it("should return 409 when same API is called twice with same session (pre-check)", async () => { + // Use sign_in_reshare because get_key_shares is NOT the final API + // (reshare is final), so session stays COMMITTED after first call + const ctx = createTestContext({ + operationType: "sign_in_reshare", + apiName: "get_key_shares", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + // First call should succeed + await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Second call should fail with API_ALREADY_CALLED (pre-check) + const response = await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(409); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("API_ALREADY_CALLED"); + }); + + it("should return 400 when same signature is reused for different API (signature mismatch)", async () => { + // When trying to reuse a signature for a different API, the signature + // verification fails first because the message includes the api_name. + // So this returns 400 INVALID_SIGNATURE, not 409. + const ctx = createTestContext({ + operationType: "sign_in_reshare", // sign_in_reshare allows multiple APIs + apiName: "get_key_shares", + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + // First call to get_key_shares + await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + // Try to call reshare with same signature + // This fails with INVALID_SIGNATURE because the signed message + // includes api_name, which is different ("get_key_shares" vs "reshare") + const response = await request(app) + .post("/test/reshare") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, // Same signature but api_name is different + auth_type: ctx.authType, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + + it("should allow different APIs with different signatures for sign_in_reshare operation", async () => { + const ctx1 = createTestContext({ + operationType: "sign_in_reshare", + apiName: "get_key_shares", + }); + await createSession(pool, ctx1); + + const signature1 = createRevealSignature( + ctx1, + mockServerKeypair.publicKey.toHex(), + ); + + // First call to get_key_shares (non-final API) + await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx1.idToken}`) + .send({ + cr_session_id: ctx1.sessionId, + cr_signature: signature1, + auth_type: ctx1.authType, + }) + .expect(200); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Create new signature for reshare API + const ctx2 = { + ...ctx1, + apiName: "reshare", + }; + const signature2 = createRevealSignature( + ctx2, + mockServerKeypair.publicKey.toHex(), + ); + + // Call reshare (final API) with different signature + const response = await request(app) + .post("/test/reshare") + .set("Authorization", `Bearer ${ctx2.idToken}`) + .send({ + cr_session_id: ctx2.sessionId, + cr_signature: signature2, + auth_type: ctx2.authType, + }) + .expect(200); + + expect(response.body.success).toBe(true); + }); + }); + + describe("final API and session completion", () => { + it("should update session to COMPLETED when final API is called for sign_in", async () => { + const ctx = createTestContext({ + operationType: "sign_in", + apiName: "get_key_shares", // final API for sign_in + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify session is COMPLETED + const sessionRes = await getCommitRevealSessionBySessionId(pool, ctx.sessionId); + if (!sessionRes.success) { + throw new Error(`Failed to get session: ${sessionRes.err}`); + } + expect(sessionRes.data?.state).toBe("COMPLETED"); + }); + + it("should update session to COMPLETED when final API is called for sign_up", async () => { + const ctx = createTestContext({ + operationType: "sign_up", + apiName: "register", // final API for sign_up + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + await request(app) + .post("/test/register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify session is COMPLETED + const sessionRes = await getCommitRevealSessionBySessionId(pool, ctx.sessionId); + if (!sessionRes.success) { + throw new Error(`Failed to get session: ${sessionRes.err}`); + } + expect(sessionRes.data?.state).toBe("COMPLETED"); + }); + + it("should NOT update session to COMPLETED when non-final API is called for sign_in_reshare", async () => { + const ctx = createTestContext({ + operationType: "sign_in_reshare", + apiName: "get_key_shares", // NOT final API for sign_in_reshare (final is reshare) + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify session is still COMMITTED (not COMPLETED) + const sessionRes = await getCommitRevealSessionBySessionId(pool, ctx.sessionId); + if (!sessionRes.success) { + throw new Error(`Failed to get session: ${sessionRes.err}`); + } + expect(sessionRes.data?.state).toBe("COMMITTED"); + }); + + it("should update session to COMPLETED when final API is called for sign_in_reshare", async () => { + const ctx = createTestContext({ + operationType: "sign_in_reshare", + apiName: "reshare", // final API for sign_in_reshare + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + await request(app) + .post("/test/reshare") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify session is COMPLETED + const sessionRes = await getCommitRevealSessionBySessionId(pool, ctx.sessionId); + if (!sessionRes.success) { + throw new Error(`Failed to get session: ${sessionRes.err}`); + } + expect(sessionRes.data?.state).toBe("COMPLETED"); + }); + + it("should update session to COMPLETED when final API is called for add_ed25519", async () => { + const ctx = createTestContext({ + operationType: "add_ed25519", + apiName: "get_key_shares", // final API for add_ed25519 + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + await request(app) + .post("/test/get_key_shares") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(200); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify session is COMPLETED + const sessionRes = await getCommitRevealSessionBySessionId(pool, ctx.sessionId); + if (!sessionRes.success) { + throw new Error(`Failed to get session: ${sessionRes.err}`); + } + expect(sessionRes.data?.state).toBe("COMPLETED"); + }); + + it("should NOT update session to COMPLETED when API fails", async () => { + const ctx = createTestContext({ + operationType: "sign_in", + apiName: "get_key_shares", // final API for sign_in + }); + await createSession(pool, ctx); + + const signature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + ); + + await request(app) + .post("/test/get_key_shares_fail") + .set("Authorization", `Bearer ${ctx.idToken}`) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: signature, + auth_type: ctx.authType, + }) + .expect(500); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify session is still COMMITTED (not COMPLETED) + const sessionRes = await getCommitRevealSessionBySessionId(pool, ctx.sessionId); + if (!sessionRes.success) { + throw new Error(`Failed to get session: ${sessionRes.err}`); + } + expect(sessionRes.data?.state).toBe("COMMITTED"); + }); + }); +}); diff --git a/key_share_node/server/src/middlewares/commit_reveal.ts b/key_share_node/server/src/middlewares/commit_reveal.ts new file mode 100644 index 000000000..6e5038a57 --- /dev/null +++ b/key_share_node/server/src/middlewares/commit_reveal.ts @@ -0,0 +1,250 @@ +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/ksn-pg-interface/commit_reveal"; + +import { ErrorCodeMap } from "@oko-wallet-ksn-server/error"; +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"; + +export interface CommitRevealBody { + cr_session_id: string; + cr_signature: string; // 128 chars hex (64 bytes) + auth_type?: string; +} + +export function commitRevealMiddleware(apiName: string) { + 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; + } + + // Get session from DB + 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 ?? "google"; + 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.serverKeypair.publicKey.toHex(); + const message = `${nodePubkeyHex}${cr_session_id}${authType}${idToken}${session.operation_type}${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"); + logger.error( + "Failed to record API call for session %s: %s", + cr_session_id, + err, + ); + } finally { + client.release(); + } + } + }); + + next(); + }; +} diff --git a/key_share_node/server/src/middlewares/index.ts b/key_share_node/server/src/middlewares/index.ts index 8ba9db8f1..2abfd7a1d 100644 --- a/key_share_node/server/src/middlewares/index.ts +++ b/key_share_node/server/src/middlewares/index.ts @@ -1,3 +1,4 @@ export * from "./auth"; export * from "./rate_limit"; export * from "./admin_auth"; +export * from "./commit_reveal"; diff --git a/key_share_node/server/src/openapi/doc.ts b/key_share_node/server/src/openapi/doc.ts index c33096044..466a57d71 100644 --- a/key_share_node/server/src/openapi/doc.ts +++ b/key_share_node/server/src/openapi/doc.ts @@ -36,6 +36,10 @@ export function getOpenApiDocument() { description: "Production server", }, ], - tags: [{ name: "Key Share" }, { name: "PG Dump" }], + tags: [ + { name: "Commit-Reveal" }, + { name: "Key Share" }, + { name: "PG Dump" }, + ], }); } diff --git a/key_share_node/server/src/openapi/schema/commit_reveal.ts b/key_share_node/server/src/openapi/schema/commit_reveal.ts new file mode 100644 index 000000000..e0b6241e6 --- /dev/null +++ b/key_share_node/server/src/openapi/schema/commit_reveal.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; + +import { registry } from "../doc"; + +export const operationTypeSchema = z + .enum([ + "sign_in", + "sign_up", + "sign_in_reshare", + "register_reshare", + "add_ed25519", + ]) + .describe("Operation type for commit-reveal session"); + +// POST /commit-reveal/v2/commit + +export const CommitRequestBodySchema = registry.register( + "CommitRequestBody", + 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("CommitRequestBody", { + description: "Request payload for creating a commit-reveal session.", + }), +); + +export const CommitResponseDataSchema = registry.register( + "CommitResponseData", + 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("CommitResponseData", { + description: "Response data containing node's public key and signature.", + }), +); + +export const CommitSuccessResponseSchema = registry.register( + "CommitSuccessResponse", + z + .object({ + success: z.literal(true), + data: CommitResponseDataSchema, + }) + .openapi("CommitSuccessResponse", { + description: "Success response for commit request.", + }), +); + +export type CommitRequestBody = z.infer; +export type CommitResponseData = z.infer; + +// 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/key_share_node/server/src/openapi/schema/index.ts b/key_share_node/server/src/openapi/schema/index.ts index 9c8fe1173..d26c290e8 100644 --- a/key_share_node/server/src/openapi/schema/index.ts +++ b/key_share_node/server/src/openapi/schema/index.ts @@ -2,3 +2,4 @@ export * from "./common"; export * from "./key_share"; export * from "./pg_dump"; export * from "./status"; +export * from "./commit_reveal"; diff --git a/key_share_node/server/src/openapi/schema/key_share_v2.ts b/key_share_node/server/src/openapi/schema/key_share_v2.ts index 29c9e9596..9a2c21130 100644 --- a/key_share_node/server/src/openapi/schema/key_share_v2.ts +++ b/key_share_node/server/src/openapi/schema/key_share_v2.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { authTypeSchema, publicKeySchema, shareSchema } from "./key_share_v1"; +import { commitRevealRequestFieldsSchema } from "./commit_reveal"; import { registry } from "../doc"; // ============================================================================ @@ -32,9 +33,10 @@ export const GetKeyShareV2RequestBodySchema = registry.register( "Object with curve_type as key and public_key as value", ), }) + .merge(commitRevealRequestFieldsSchema) .openapi("GetKeyShareV2RequestBody", { description: - "Request payload for retrieving multiple key shares at once.", + "Request payload for retrieving multiple key shares at once. Requires commit-reveal session.", }), ); @@ -153,9 +155,10 @@ export const RegisterKeyShareV2RequestBodySchema = registry.register( "Both secp256k1 and ed25519 wallet info are required", ), }) + .merge(commitRevealRequestFieldsSchema) .openapi("RegisterKeyShareV2RequestBody", { description: - "Request payload for registering key shares. Both secp256k1 and ed25519 wallets are required.", + "Request payload for registering key shares. Both secp256k1 and ed25519 wallets are required. Requires commit-reveal session.", }), ); @@ -185,9 +188,10 @@ export const RegisterEd25519V2RequestBodySchema = registry.register( .describe("ed25519 public key (32 bytes hex)"), share: shareSchema.describe("Key share in hex string format (64 bytes)"), }) + .merge(commitRevealRequestFieldsSchema) .openapi("RegisterEd25519V2RequestBody", { description: - "Request payload for registering ed25519 wallet for existing users.", + "Request payload for registering ed25519 wallet for existing users. Requires commit-reveal session.", }), ); @@ -233,8 +237,10 @@ export const ReshareKeyShareV2RequestBodySchema = registry.register( "Object with curve_type as key and wallet reshare info as value", ), }) + .merge(commitRevealRequestFieldsSchema) .openapi("ReshareKeyShareV2RequestBody", { - description: "Request payload for resharing multiple key shares at once.", + description: + "Request payload for resharing multiple key shares at once. Requires commit-reveal session.", }), ); @@ -275,9 +281,10 @@ export const ReshareRegisterV2RequestBodySchema = registry.register( "Object with curve_type as key and wallet info as value", ), }) + .merge(commitRevealRequestFieldsSchema) .openapi("ReshareRegisterV2RequestBody", { description: - "Request payload for registering key shares during reshare (new node joining). User must already exist.", + "Request payload for registering key shares during reshare (new node joining). User must already exist. Requires commit-reveal session.", }), ); diff --git a/key_share_node/server/src/routes/commit_reveal/commit.test.ts b/key_share_node/server/src/routes/commit_reveal/commit.test.ts new file mode 100644 index 000000000..4f14a23a6 --- /dev/null +++ b/key_share_node/server/src/routes/commit_reveal/commit.test.ts @@ -0,0 +1,427 @@ +import request from "supertest"; +import express from "express"; +import { Pool } from "pg"; +import dayjs from "dayjs"; +import { Bytes } from "@oko-wallet/bytes"; +import { randomBytes } from "node:crypto"; +import { v4 as uuidv4 } from "uuid"; + +import { connectPG, resetPgDatabase } from "@oko-wallet-ksn-server/database"; +import { testPgConfig } from "@oko-wallet-ksn-server/database/test_config"; +import { makeCommitRevealRouter } from "."; +import type { ServerState } from "@oko-wallet-ksn-server/state"; + +// 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, +}; + +function generateRandomHex(bytes: number): string { + return randomBytes(bytes).toString("hex"); +} + +describe("commit_reveal_commit_test", () => { + let pool: Pool; + let app: express.Application; + + beforeAll(async () => { + const config = testPgConfig; + const createPostgresRes = await connectPG({ + 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()); + + const commitRevealRouter = makeCommitRevealRouter(); + app.use("/commit-reveal/v2", commitRevealRouter); + + app.locals = { + db: pool, + encryptionSecret: "temp_enc_secret", + serverKeypair: mockServerKeypair, + telegram_bot_token: "temp_telegram_bot_token", + is_db_backup_checked: false, + launch_time: dayjs().toISOString(), + git_hash: "", + version: "", + } satisfies ServerState; + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + }); + + afterAll(async () => { + await pool.end(); + }); + + const testEndpoint = "/commit-reveal/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 register_reshare operation", async () => { + const body = { + ...createValidBody(), + operation_type: "register_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("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 (DB error)", 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 (DB error)", 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); + }); + }); + + 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 "2_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 "2_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(), + ); + }); + }); +}); diff --git a/key_share_node/server/src/routes/commit_reveal/commit.ts b/key_share_node/server/src/routes/commit_reveal/commit.ts new file mode 100644 index 000000000..ac64d362b --- /dev/null +++ b/key_share_node/server/src/routes/commit_reveal/commit.ts @@ -0,0 +1,174 @@ +import { type Request, type Response } from "express"; +import { Bytes } from "@oko-wallet/bytes"; +import type { KSNodeApiResponse } from "@oko-wallet/ksn-interface/response"; +import { + signMessage, + convertEddsaSignatureToBytes, +} from "@oko-wallet/crypto-js/node/ecdhe"; +import { createCommitRevealSession } from "@oko-wallet/ksn-pg-interface/commit_reveal"; + +import { registry } from "@oko-wallet-ksn-server/openapi/doc"; +import { + CommitRequestBodySchema, + CommitSuccessResponseSchema, + ErrorResponseSchema, + type CommitRequestBody, + type CommitResponseData, +} from "@oko-wallet-ksn-server/openapi/schema"; +import { ErrorCodeMap } from "@oko-wallet-ksn-server/error"; +import type { ServerState } from "@oko-wallet-ksn-server/state"; + +const SESSION_EXPIRY_MINUTES = 5; + +registry.registerPath({ + method: "post", + path: "/commit-reveal/v2/commit", + tags: ["Commit-Reveal"], + 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(ErrorCodeMap.SESSION_ALREADY_EXISTS).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.serverKeypair.publicKey.toHex(); + const signResult = signMessage(nodePubkeyHex, state.serverKeypair.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/key_share_node/server/src/routes/commit_reveal/index.ts b/key_share_node/server/src/routes/commit_reveal/index.ts new file mode 100644 index 000000000..68a2ad797 --- /dev/null +++ b/key_share_node/server/src/routes/commit_reveal/index.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; + +import { commitRevealCommit } from "./commit"; + +export function makeCommitRevealRouter() { + const router = Router(); + + router.post("/commit", commitRevealCommit); + + return router; +} diff --git a/key_share_node/server/src/routes/index.ts b/key_share_node/server/src/routes/index.ts index c2adc1740..2bda3d933 100644 --- a/key_share_node/server/src/routes/index.ts +++ b/key_share_node/server/src/routes/index.ts @@ -4,6 +4,7 @@ import { makePgDumpRouter } from "./pg_dump"; import { addStatusRoutes } from "./status"; import { makeKeyshareRouter } from "./key_share/v1"; import { makeKeyshareV2Router } from "./key_share_v2"; +import { makeCommitRevealRouter } from "./commit_reveal"; export function setRoutes(app: Express) { const keyshareRouter = makeKeyshareRouter(); @@ -16,5 +17,8 @@ export function setRoutes(app: Express) { const keyshareV2Router = makeKeyshareV2Router(); app.use("/keyshare/v2", keyshareV2Router); + const commitRevealRouter = makeCommitRevealRouter(); + app.use("/commit-reveal/v2", commitRevealRouter); + addStatusRoutes(app); } 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 new file mode 100644 index 000000000..eaf06468f --- /dev/null +++ b/key_share_node/server/src/routes/key_share_v2/e2e.test.ts @@ -0,0 +1,989 @@ +/** + * E2E Integration Tests for Commit-Reveal + KeyShare v2 APIs + * + * Tests the full flow: + * 1. Commit phase - POST /commit-reveal/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"; +import dayjs from "dayjs"; +import { Bytes } from "@oko-wallet/bytes"; +import { v4 as uuidv4 } from "uuid"; +import { + generateEddsaKeypair, + signMessage, + 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 { makeCommitRevealRouter } from "@oko-wallet-ksn-server/routes/commit_reveal"; +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"; +import { getKeysharesV2 } from "./get_key_shares"; +import { registerKeyshareEd25519 } from "./ed25519"; +import { keyshareV2Reshare } from "./reshare"; + +// Mock server keypair (must match the one used by the commit endpoint) +const serverPrivateKeyRes = Bytes.fromHexString( + "0000000000000000000000000000000000000000000000000000000000000001", + 32, +); +const serverPublicKeyRes = Bytes.fromHexString( + "4cb5abf6ad79fbf5abbccafcc269d85cd2651ed4b885b5869f241aedf0a5ba29", + 32, +); +if (!serverPrivateKeyRes.success || !serverPublicKeyRes.success) { + throw new Error("Failed to create mock server keypair"); +} +const mockServerKeypair = { + privateKey: serverPrivateKeyRes.data, + publicKey: serverPublicKeyRes.data, +}; + +// Test data +const TEST_SECP256K1_PK = + "028812785B3F855F677594A6FEB76CA3FD39F2CA36AC5A8454A1417C4232AC566D"; +const TEST_ED25519_PK = + "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"; +const TEST_ENC_SECRET = "test_enc_secret"; + +// Generate random 64-byte share (hex) +function generateRandomShare(): string { + const arr = Array.from({ length: 64 }, () => + Math.floor(Math.random() * 256), + ); + return Buffer.from(arr).toString("hex"); +} + +interface E2ETestContext { + sessionId: string; + clientKeypair: { + privateKey: Bytes<32>; + publicKey: Bytes<32>; + }; + idToken: string; + authType: string; + operationType: OperationType; + userIdentifier: string; +} + +function createE2ETestContext( + overrides: Partial = {}, +): E2ETestContext { + const keypairRes = generateEddsaKeypair(); + if (!keypairRes.success) { + throw new Error(`Failed to generate keypair: ${keypairRes.err}`); + } + + const idToken = `test_id_token_${Date.now()}_${Math.random().toString(36).slice(2)}`; + + return { + sessionId: uuidv4(), + clientKeypair: keypairRes.data, + idToken, + authType: "google", + operationType: "sign_up", + userIdentifier: `google_test_user_${Date.now()}`, + ...overrides, + }; +} + +function computeIdTokenHash(authType: string, idToken: string): string { + const hashRes = sha256(`${authType}${idToken}`); + if (!hashRes.success) { + throw new Error(`Failed to compute hash: ${hashRes.err}`); + } + return hashRes.data.toHex(); +} + +function createRevealSignature( + ctx: E2ETestContext, + nodePubkeyHex: string, + apiName: string, +): string { + const message = `${nodePubkeyHex}${ctx.sessionId}${ctx.authType}${ctx.idToken}${ctx.operationType}${apiName}`; + const signRes = signMessage(message, ctx.clientKeypair.privateKey); + if (!signRes.success) { + throw new Error(`Failed to sign message: ${signRes.err}`); + } + const sigBytesRes = convertEddsaSignatureToBytes(signRes.data); + if (!sigBytesRes.success) { + throw new Error(`Failed to convert signature: ${sigBytesRes.err}`); + } + return sigBytesRes.data.toHex(); +} + +/** + * Mock OAuth middleware that bypasses actual token validation + * and sets res.locals.oauth_user based on a custom header + */ +function mockOAuthMiddleware( + req: express.Request, + res: express.Response, + next: express.NextFunction, +): void { + const mockUserId = req.headers["x-mock-user-id"] as string; + const authType = (req.body?.auth_type ?? "google") as string; + + if (!mockUserId) { + res.status(401).json({ + success: false, + code: "UNAUTHORIZED", + msg: "x-mock-user-id header required for testing", + }); + return; + } + + res.locals.oauth_user = { + type: authType, + user_identifier: mockUserId, + }; + + next(); +} + +describe("e2e_commit_reveal_keyshare_test", () => { + let pool: Pool; + let app: express.Application; + + beforeAll(async () => { + const config = testPgConfig; + const createPostgresRes = await connectPG({ + 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; + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + + // Create fresh app for each test + app = express(); + app.use(express.json()); + + app.locals = { + db: pool, + encryptionSecret: TEST_ENC_SECRET, + serverKeypair: mockServerKeypair, + telegram_bot_token: "temp_telegram_bot_token", + is_db_backup_checked: false, + launch_time: dayjs().toISOString(), + git_hash: "", + version: "", + } satisfies ServerState; + + // Mount commit-reveal router + const commitRevealRouter = makeCommitRevealRouter(); + app.use("/commit-reveal/v2", commitRevealRouter); + + // Mount keyshare v2 routes with commit-reveal middleware + mock OAuth + app.post( + "/keyshare/v2", + commitRevealMiddleware("get_key_shares"), + mockOAuthMiddleware, + getKeysharesV2, + ); + + app.post( + "/keyshare/v2/register", + commitRevealMiddleware("register"), + mockOAuthMiddleware, + keyshareV2Register, + ); + + app.post( + "/keyshare/v2/register/ed25519", + commitRevealMiddleware("register_ed25519"), + mockOAuthMiddleware, + registerKeyshareEd25519, + ); + + app.post( + "/keyshare/v2/reshare", + commitRevealMiddleware("reshare"), + mockOAuthMiddleware, + keyshareV2Reshare, + ); + }); + + afterAll(async () => { + await pool.end(); + }); + + describe("sign_up flow (commit → register)", () => { + it("should complete full sign_up flow: commit → register → verify data", async () => { + const ctx = createE2ETestContext({ operationType: "sign_up" }); + const idTokenHash = computeIdTokenHash(ctx.authType, ctx.idToken); + + // Step 1: Commit + const commitResponse = await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: ctx.sessionId, + operation_type: ctx.operationType, + client_ephemeral_pubkey: ctx.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(), + ); + + // Step 2: Register with commit-reveal signature + const registerSignature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + "register", + ); + + const secp256k1Share = generateRandomShare(); + const ed25519Share = generateRandomShare(); + + const registerResponse = await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .set("x-mock-user-id", ctx.userIdentifier) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: registerSignature, + auth_type: ctx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: secp256k1Share, + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: ed25519Share, + }, + }, + }) + .expect(200); + + expect(registerResponse.body.success).toBe(true); + + // Wait for res.on('finish') to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Step 3: Verify session is COMPLETED + const sessionRes = await getCommitRevealSessionBySessionId( + pool, + ctx.sessionId, + ); + expect(sessionRes.success).toBe(true); + if (sessionRes.success) { + expect(sessionRes.data?.state).toBe("COMPLETED"); + } + + // Step 4: Verify key shares were persisted + const secp256k1PkBytes = Bytes.fromHexString(TEST_SECP256K1_PK, 33); + const ed25519PkBytes = Bytes.fromHexString(TEST_ED25519_PK, 32); + if (!secp256k1PkBytes.success || !ed25519PkBytes.success) { + throw new Error("Failed to parse public keys"); + } + + const checkResult = await checkKeyShareV2(pool, { + user_auth_id: ctx.userIdentifier, + auth_type: ctx.authType as "google", + wallets: { + secp256k1: secp256k1PkBytes.data, + ed25519: ed25519PkBytes.data, + }, + }); + + expect(checkResult.success).toBe(true); + if (checkResult.success) { + expect(checkResult.data.secp256k1?.exists).toBe(true); + expect(checkResult.data.ed25519?.exists).toBe(true); + } + }); + + it("should reject replay attack on register API", async () => { + const ctx = createE2ETestContext({ operationType: "sign_up" }); + const idTokenHash = computeIdTokenHash(ctx.authType, ctx.idToken); + + // Commit + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: ctx.sessionId, + operation_type: ctx.operationType, + client_ephemeral_pubkey: ctx.clientKeypair.publicKey.toHex(), + id_token_hash: idTokenHash, + }) + .expect(200); + + // First register call + const registerSignature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + "register", + ); + + await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .set("x-mock-user-id", ctx.userIdentifier) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: registerSignature, + auth_type: ctx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: generateRandomShare(), + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: generateRandomShare(), + }, + }, + }) + .expect(200); + + // Wait for completion + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Second register call with same session should fail + // Session is now COMPLETED, so it fails with INVALID_REQUEST (not COMMITTED state) + const response = await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .set("x-mock-user-id", ctx.userIdentifier) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: registerSignature, + auth_type: ctx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: generateRandomShare(), + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: generateRandomShare(), + }, + }, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("COMMITTED"); + }); + }); + + describe("sign_in flow (commit → get_key_shares)", () => { + it("should complete full sign_in flow: register → commit → get_key_shares", async () => { + // First, register a user (using sign_up flow) + const signUpCtx = createE2ETestContext({ operationType: "sign_up" }); + const signUpIdTokenHash = computeIdTokenHash( + signUpCtx.authType, + signUpCtx.idToken, + ); + + // Commit for sign_up + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: signUpCtx.sessionId, + operation_type: signUpCtx.operationType, + client_ephemeral_pubkey: signUpCtx.clientKeypair.publicKey.toHex(), + id_token_hash: signUpIdTokenHash, + }) + .expect(200); + + // Register + const registerSignature = createRevealSignature( + signUpCtx, + mockServerKeypair.publicKey.toHex(), + "register", + ); + + const secp256k1Share = generateRandomShare(); + const ed25519Share = generateRandomShare(); + + await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${signUpCtx.idToken}`) + .set("x-mock-user-id", signUpCtx.userIdentifier) + .send({ + cr_session_id: signUpCtx.sessionId, + cr_signature: registerSignature, + auth_type: signUpCtx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: secp256k1Share, + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: ed25519Share, + }, + }, + }) + .expect(200); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Now sign_in flow + const signInCtx = createE2ETestContext({ + operationType: "sign_in", + userIdentifier: signUpCtx.userIdentifier, // Same user + }); + const signInIdTokenHash = computeIdTokenHash( + signInCtx.authType, + signInCtx.idToken, + ); + + // Commit for sign_in + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: signInCtx.sessionId, + operation_type: signInCtx.operationType, + client_ephemeral_pubkey: signInCtx.clientKeypair.publicKey.toHex(), + id_token_hash: signInIdTokenHash, + }) + .expect(200); + + // Get key shares + const getSignature = createRevealSignature( + signInCtx, + mockServerKeypair.publicKey.toHex(), + "get_key_shares", + ); + + const getResponse = await request(app) + .post("/keyshare/v2") + .set("Authorization", `Bearer ${signInCtx.idToken}`) + .set("x-mock-user-id", signInCtx.userIdentifier) + .send({ + cr_session_id: signInCtx.sessionId, + cr_signature: getSignature, + auth_type: signInCtx.authType, + wallets: { + secp256k1: TEST_SECP256K1_PK, + ed25519: TEST_ED25519_PK, + }, + }) + .expect(200); + + expect(getResponse.body.success).toBe(true); + expect(getResponse.body.data.secp256k1).toBeDefined(); + expect(getResponse.body.data.secp256k1.share).toBe(secp256k1Share); + expect(getResponse.body.data.ed25519).toBeDefined(); + expect(getResponse.body.data.ed25519.share).toBe(ed25519Share); + + // Wait for completion + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify sign_in session is COMPLETED + const sessionRes = await getCommitRevealSessionBySessionId( + pool, + signInCtx.sessionId, + ); + expect(sessionRes.success).toBe(true); + if (sessionRes.success) { + expect(sessionRes.data?.state).toBe("COMPLETED"); + } + }); + }); + + describe("sign_in_reshare flow (commit → get_key_shares → reshare)", () => { + it("should complete sign_in_reshare flow with multiple API calls", async () => { + // First, register a user + const signUpCtx = createE2ETestContext({ operationType: "sign_up" }); + const signUpIdTokenHash = computeIdTokenHash( + signUpCtx.authType, + signUpCtx.idToken, + ); + + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: signUpCtx.sessionId, + operation_type: signUpCtx.operationType, + client_ephemeral_pubkey: signUpCtx.clientKeypair.publicKey.toHex(), + id_token_hash: signUpIdTokenHash, + }) + .expect(200); + + const registerSignature = createRevealSignature( + signUpCtx, + mockServerKeypair.publicKey.toHex(), + "register", + ); + + const secp256k1Share = generateRandomShare(); + const ed25519Share = generateRandomShare(); + + await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${signUpCtx.idToken}`) + .set("x-mock-user-id", signUpCtx.userIdentifier) + .send({ + cr_session_id: signUpCtx.sessionId, + cr_signature: registerSignature, + auth_type: signUpCtx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: secp256k1Share, + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: ed25519Share, + }, + }, + }) + .expect(200); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Now sign_in_reshare flow + const reshareCtx = createE2ETestContext({ + operationType: "sign_in_reshare", + userIdentifier: signUpCtx.userIdentifier, + }); + const reshareIdTokenHash = computeIdTokenHash( + reshareCtx.authType, + reshareCtx.idToken, + ); + + // Commit for sign_in_reshare + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: reshareCtx.sessionId, + operation_type: reshareCtx.operationType, + client_ephemeral_pubkey: reshareCtx.clientKeypair.publicKey.toHex(), + id_token_hash: reshareIdTokenHash, + }) + .expect(200); + + // Step 1: get_key_shares (non-final API) + const getSignature = createRevealSignature( + reshareCtx, + mockServerKeypair.publicKey.toHex(), + "get_key_shares", + ); + + await request(app) + .post("/keyshare/v2") + .set("Authorization", `Bearer ${reshareCtx.idToken}`) + .set("x-mock-user-id", reshareCtx.userIdentifier) + .send({ + cr_session_id: reshareCtx.sessionId, + cr_signature: getSignature, + auth_type: reshareCtx.authType, + wallets: { + secp256k1: TEST_SECP256K1_PK, + }, + }) + .expect(200); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify session is still COMMITTED (get_key_shares is not final for sign_in_reshare) + let sessionRes = await getCommitRevealSessionBySessionId( + pool, + reshareCtx.sessionId, + ); + expect(sessionRes.success).toBe(true); + if (sessionRes.success) { + expect(sessionRes.data?.state).toBe("COMMITTED"); + } + + // Step 2: reshare (final API) + const reshareSignature = createRevealSignature( + reshareCtx, + mockServerKeypair.publicKey.toHex(), + "reshare", + ); + + await request(app) + .post("/keyshare/v2/reshare") + .set("Authorization", `Bearer ${reshareCtx.idToken}`) + .set("x-mock-user-id", reshareCtx.userIdentifier) + .send({ + cr_session_id: reshareCtx.sessionId, + cr_signature: reshareSignature, + auth_type: reshareCtx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: secp256k1Share, // Must match the original share + }, + }, + }) + .expect(200); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify session is now COMPLETED + sessionRes = await getCommitRevealSessionBySessionId( + pool, + reshareCtx.sessionId, + ); + expect(sessionRes.success).toBe(true); + if (sessionRes.success) { + expect(sessionRes.data?.state).toBe("COMPLETED"); + } + }); + }); + + describe("add_ed25519 flow (commit → register_ed25519 → get_key_shares)", () => { + it("should reject adding same ed25519 public key twice", async () => { + // First, sign up with both wallets + const signUpCtx = createE2ETestContext({ operationType: "sign_up" }); + const signUpIdTokenHash = computeIdTokenHash( + signUpCtx.authType, + signUpCtx.idToken, + ); + + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: signUpCtx.sessionId, + operation_type: signUpCtx.operationType, + client_ephemeral_pubkey: signUpCtx.clientKeypair.publicKey.toHex(), + id_token_hash: signUpIdTokenHash, + }) + .expect(200); + + const registerSignature = createRevealSignature( + signUpCtx, + mockServerKeypair.publicKey.toHex(), + "register", + ); + + const secp256k1Share = generateRandomShare(); + const ed25519Share = generateRandomShare(); + + await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${signUpCtx.idToken}`) + .set("x-mock-user-id", signUpCtx.userIdentifier) + .send({ + cr_session_id: signUpCtx.sessionId, + cr_signature: registerSignature, + auth_type: signUpCtx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: secp256k1Share, + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: ed25519Share, + }, + }, + }) + .expect(200); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Now try to add the SAME ed25519 public key again + const addEd25519Ctx = createE2ETestContext({ + operationType: "add_ed25519", + userIdentifier: signUpCtx.userIdentifier, + }); + const addEd25519IdTokenHash = computeIdTokenHash( + addEd25519Ctx.authType, + addEd25519Ctx.idToken, + ); + + // Commit for add_ed25519 + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: addEd25519Ctx.sessionId, + operation_type: addEd25519Ctx.operationType, + client_ephemeral_pubkey: + addEd25519Ctx.clientKeypair.publicKey.toHex(), + id_token_hash: addEd25519IdTokenHash, + }) + .expect(200); + + // Try to register_ed25519 with the SAME public key (should fail) + const registerEd25519Signature = createRevealSignature( + addEd25519Ctx, + mockServerKeypair.publicKey.toHex(), + "register_ed25519", + ); + + const newEd25519Share = generateRandomShare(); + + // This should fail with DUPLICATE_PUBLIC_KEY because the public key already exists + const response = await request(app) + .post("/keyshare/v2/register/ed25519") + .set("Authorization", `Bearer ${addEd25519Ctx.idToken}`) + .set("x-mock-user-id", addEd25519Ctx.userIdentifier) + .send({ + cr_session_id: addEd25519Ctx.sessionId, + cr_signature: registerEd25519Signature, + auth_type: addEd25519Ctx.authType, + public_key: TEST_ED25519_PK, // Same public key as before + share: newEd25519Share, + }) + .expect(409); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("DUPLICATE_PUBLIC_KEY"); + }); + }); + + describe("error scenarios", () => { + it("should reject request with invalid signature", async () => { + const ctx = createE2ETestContext({ operationType: "sign_up" }); + const idTokenHash = computeIdTokenHash(ctx.authType, ctx.idToken); + + // Commit + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: ctx.sessionId, + operation_type: ctx.operationType, + client_ephemeral_pubkey: ctx.clientKeypair.publicKey.toHex(), + id_token_hash: idTokenHash, + }) + .expect(200); + + // Try to register with invalid signature + const response = await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .set("x-mock-user-id", ctx.userIdentifier) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: "a".repeat(128), // Invalid signature + auth_type: ctx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: generateRandomShare(), + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: generateRandomShare(), + }, + }, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_SIGNATURE"); + }); + + it("should reject request with expired session", async () => { + const ctx = createE2ETestContext({ operationType: "sign_up" }); + const idTokenHash = computeIdTokenHash(ctx.authType, ctx.idToken); + + // Commit + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: ctx.sessionId, + operation_type: ctx.operationType, + client_ephemeral_pubkey: ctx.clientKeypair.publicKey.toHex(), + id_token_hash: idTokenHash, + }) + .expect(200); + + // Manually expire the session + await pool.query( + 'UPDATE "2_commit_reveal_sessions" SET expires_at = NOW() - INTERVAL \'1 minute\' WHERE session_id = $1', + [ctx.sessionId], + ); + + // Try to register with expired session + const registerSignature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + "register", + ); + + const response = await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .set("x-mock-user-id", ctx.userIdentifier) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: registerSignature, + auth_type: ctx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: generateRandomShare(), + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: generateRandomShare(), + }, + }, + }) + .expect(410); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("SESSION_EXPIRED"); + }); + + it("should reject request with non-existent session", async () => { + const ctx = createE2ETestContext({ operationType: "sign_up" }); + + // Don't commit, try to register directly + const registerSignature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + "register", + ); + + const response = await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .set("x-mock-user-id", ctx.userIdentifier) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: registerSignature, + auth_type: ctx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: generateRandomShare(), + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: generateRandomShare(), + }, + }, + }) + .expect(404); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("SESSION_NOT_FOUND"); + }); + + it("should reject request with wrong operation type for API", async () => { + const ctx = createE2ETestContext({ + operationType: "sign_in", // sign_in only allows get_key_shares + }); + const idTokenHash = computeIdTokenHash(ctx.authType, ctx.idToken); + + // Commit with sign_in operation + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: ctx.sessionId, + operation_type: ctx.operationType, + client_ephemeral_pubkey: ctx.clientKeypair.publicKey.toHex(), + id_token_hash: idTokenHash, + }) + .expect(200); + + // Try to call register (not allowed for sign_in) + const registerSignature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + "register", + ); + + const response = await request(app) + .post("/keyshare/v2/register") + .set("Authorization", `Bearer ${ctx.idToken}`) + .set("x-mock-user-id", ctx.userIdentifier) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: registerSignature, + auth_type: ctx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: generateRandomShare(), + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: generateRandomShare(), + }, + }, + }) + .expect(400); + + expect(response.body.success).toBe(false); + expect(response.body.code).toBe("INVALID_REQUEST"); + expect(response.body.msg).toContain("not allowed"); + }); + + it("should reject id_token_hash mismatch", async () => { + const ctx = createE2ETestContext({ operationType: "sign_up" }); + const idTokenHash = computeIdTokenHash(ctx.authType, ctx.idToken); + + // Commit + await request(app) + .post("/commit-reveal/v2/commit") + .send({ + session_id: ctx.sessionId, + operation_type: ctx.operationType, + client_ephemeral_pubkey: ctx.clientKeypair.publicKey.toHex(), + id_token_hash: idTokenHash, + }) + .expect(200); + + // Try to register with different id_token + const registerSignature = createRevealSignature( + ctx, + mockServerKeypair.publicKey.toHex(), + "register", + ); + + const response = await request(app) + .post("/keyshare/v2/register") + .set("Authorization", "Bearer different_token") // Different token + .set("x-mock-user-id", ctx.userIdentifier) + .send({ + cr_session_id: ctx.sessionId, + cr_signature: registerSignature, + auth_type: ctx.authType, + wallets: { + secp256k1: { + public_key: TEST_SECP256K1_PK, + share: generateRandomShare(), + }, + ed25519: { + public_key: TEST_ED25519_PK, + share: generateRandomShare(), + }, + }, + }) + .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/key_share_node/server/src/routes/key_share_v2/ed25519.ts b/key_share_node/server/src/routes/key_share_v2/ed25519.ts index 6320fad55..f6b25b28e 100644 --- a/key_share_node/server/src/routes/key_share_v2/ed25519.ts +++ b/key_share_node/server/src/routes/key_share_v2/ed25519.ts @@ -18,10 +18,10 @@ import { registry.registerPath({ method: "post", path: "/keyshare/v2/register/ed25519", - tags: ["Key Share v2"], + tags: ["Key Share v2", "Commit-Reveal"], summary: "Register ed25519 wallet for existing users", description: - "Register ed25519 wallet for users who already have secp256k1 wallet.", + "Register ed25519 wallet for users who already have secp256k1 wallet. Requires commit-reveal authentication.", security: [{ oauthAuth: [] }], request: { body: { @@ -62,11 +62,18 @@ registry.registerPath({ msg: "Share is not valid", }, }, - DUPLICATE_PUBLIC_KEY: { + INVALID_REQUEST: { value: { success: false, - code: "DUPLICATE_PUBLIC_KEY", - msg: "ed25519 wallet already exists", + code: "INVALID_REQUEST", + msg: "cr_session_id and cr_signature are required", + }, + }, + INVALID_SIGNATURE: { + value: { + success: false, + code: "INVALID_SIGNATURE", + msg: "Invalid signature", }, }, }, @@ -82,7 +89,7 @@ registry.registerPath({ }, }, 404: { - description: "Not found - User or secp256k1 wallet not found", + description: "Not found - User, secp256k1 wallet or session not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -101,6 +108,54 @@ registry.registerPath({ msg: "secp256k1 wallet not found (not an existing user)", }, }, + SESSION_NOT_FOUND: { + value: { + success: false, + code: "SESSION_NOT_FOUND", + msg: "Session not found", + }, + }, + }, + }, + }, + }, + 409: { + description: "Conflict - Duplicate public key or API already called", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + DUPLICATE_PUBLIC_KEY: { + value: { + success: false, + code: "DUPLICATE_PUBLIC_KEY", + msg: "ed25519 wallet already exists", + }, + }, + API_ALREADY_CALLED: { + value: { + success: false, + code: "API_ALREADY_CALLED", + msg: 'API "register_ed25519" has already been called for this session', + }, + }, + }, + }, + }, + }, + 410: { + description: "Gone - Session has expired", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + SESSION_EXPIRED: { + value: { + success: false, + code: "SESSION_EXPIRED", + msg: "Session has expired", + }, + }, }, }, }, diff --git a/key_share_node/server/src/routes/key_share_v2/get_key_shares.ts b/key_share_node/server/src/routes/key_share_v2/get_key_shares.ts index 3c055cbed..9d3c421f6 100644 --- a/key_share_node/server/src/routes/key_share_v2/get_key_shares.ts +++ b/key_share_node/server/src/routes/key_share_v2/get_key_shares.ts @@ -21,10 +21,10 @@ import { registry.registerPath({ method: "post", path: "/keyshare/v2", - tags: ["Key Share v2"], + tags: ["Key Share v2", "Commit-Reveal"], summary: "Get multiple key shares", description: - "Retrieve multiple key shares for the authenticated user in a single request.", + "Retrieve multiple key shares for the authenticated user in a single request. Requires commit-reveal authentication.", security: [{ oauthAuth: [] }], request: { body: { @@ -58,6 +58,20 @@ registry.registerPath({ msg: "Public key is not valid", }, }, + INVALID_REQUEST: { + value: { + success: false, + code: "INVALID_REQUEST", + msg: "cr_session_id and cr_signature are required", + }, + }, + INVALID_SIGNATURE: { + value: { + success: false, + code: "INVALID_SIGNATURE", + msg: "Invalid signature", + }, + }, }, }, }, @@ -71,7 +85,7 @@ registry.registerPath({ }, }, 404: { - description: "Not found - User, wallet or key share not found", + description: "Not found - User, wallet, key share or session not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -97,6 +111,47 @@ registry.registerPath({ msg: "Key share not found for curve_type: ed25519", }, }, + SESSION_NOT_FOUND: { + value: { + success: false, + code: "SESSION_NOT_FOUND", + msg: "Session not found", + }, + }, + }, + }, + }, + }, + 409: { + description: "Conflict - API already called for this session", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + API_ALREADY_CALLED: { + value: { + success: false, + code: "API_ALREADY_CALLED", + msg: 'API "get_key_shares" has already been called for this session', + }, + }, + }, + }, + }, + }, + 410: { + description: "Gone - Session has expired", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + SESSION_EXPIRED: { + value: { + success: false, + code: "SESSION_EXPIRED", + msg: "Session has expired", + }, + }, }, }, }, diff --git a/key_share_node/server/src/routes/key_share_v2/index.ts b/key_share_node/server/src/routes/key_share_v2/index.ts index 06be47a98..4e716dde0 100644 --- a/key_share_node/server/src/routes/key_share_v2/index.ts +++ b/key_share_node/server/src/routes/key_share_v2/index.ts @@ -1,6 +1,9 @@ import { Router } from "express"; -import { bearerTokenMiddleware } from "@oko-wallet-ksn-server/middlewares"; +import { + bearerTokenMiddleware, + commitRevealMiddleware, +} from "@oko-wallet-ksn-server/middlewares"; import { getKeysharesV2 } from "./get_key_shares"; import { keyshareV2Check } from "./check"; import { keyshareV2Register } from "./register"; @@ -11,22 +14,39 @@ import { keyshareV2ReshareRegister } from "./reshare_register"; export function makeKeyshareV2Router() { const router = Router(); - router.post("/", bearerTokenMiddleware, getKeysharesV2); + router.post( + "/", + commitRevealMiddleware("get_key_shares"), + bearerTokenMiddleware, + getKeysharesV2, + ); router.post("/check", keyshareV2Check); - router.post("/register", bearerTokenMiddleware, keyshareV2Register); + router.post( + "/register", + commitRevealMiddleware("register"), + bearerTokenMiddleware, + keyshareV2Register, + ); router.post( "/register/ed25519", + commitRevealMiddleware("register_ed25519"), bearerTokenMiddleware, registerKeyshareEd25519, ); - router.post("/reshare", bearerTokenMiddleware, keyshareV2Reshare); + router.post( + "/reshare", + commitRevealMiddleware("reshare"), + bearerTokenMiddleware, + keyshareV2Reshare, + ); router.post( "/reshare/register", + commitRevealMiddleware("reshare_register"), bearerTokenMiddleware, keyshareV2ReshareRegister, ); diff --git a/key_share_node/server/src/routes/key_share_v2/register.ts b/key_share_node/server/src/routes/key_share_v2/register.ts index 306bb1c17..916444803 100644 --- a/key_share_node/server/src/routes/key_share_v2/register.ts +++ b/key_share_node/server/src/routes/key_share_v2/register.ts @@ -21,10 +21,10 @@ import { registry.registerPath({ method: "post", path: "/keyshare/v2/register", - tags: ["Key Share v2"], + tags: ["Key Share v2", "Commit-Reveal"], summary: "Register key shares (both curves required)", description: - "Register key shares for the authenticated user. Both secp256k1 and ed25519 wallets are required.", + "Register key shares for the authenticated user. Both secp256k1 and ed25519 wallets are required. Requires commit-reveal authentication.", security: [{ oauthAuth: [] }], request: { body: { @@ -72,12 +72,44 @@ registry.registerPath({ msg: "Share is not valid", }, }, + INVALID_SIGNATURE: { + value: { + success: false, + code: "INVALID_SIGNATURE", + msg: "Invalid signature", + }, + }, + }, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing bearer token", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: "Not found - Session not found", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + SESSION_NOT_FOUND: { + value: { + success: false, + code: "SESSION_NOT_FOUND", + msg: "Session not found", + }, + }, }, }, }, }, 409: { - description: "Conflict - Duplicate public key", + description: "Conflict - Duplicate public key or API already called", content: { "application/json": { schema: ErrorResponseSchema, @@ -89,15 +121,31 @@ registry.registerPath({ msg: "Duplicate public key for curve_type: secp256k1", }, }, + API_ALREADY_CALLED: { + value: { + success: false, + code: "API_ALREADY_CALLED", + msg: 'API "register" has already been called for this session', + }, + }, }, }, }, }, - 401: { - description: "Unauthorized - Invalid or missing bearer token", + 410: { + description: "Gone - Session has expired", content: { "application/json": { schema: ErrorResponseSchema, + examples: { + SESSION_EXPIRED: { + value: { + success: false, + code: "SESSION_EXPIRED", + msg: "Session has expired", + }, + }, + }, }, }, }, diff --git a/key_share_node/server/src/routes/key_share_v2/reshare.ts b/key_share_node/server/src/routes/key_share_v2/reshare.ts index e908bfff7..463fd0860 100644 --- a/key_share_node/server/src/routes/key_share_v2/reshare.ts +++ b/key_share_node/server/src/routes/key_share_v2/reshare.ts @@ -21,10 +21,10 @@ import { registry.registerPath({ method: "post", path: "/keyshare/v2/reshare", - tags: ["Key Share v2"], + tags: ["Key Share v2", "Commit-Reveal"], summary: "Reshare multiple key shares", description: - "Validate and update reshared_at timestamp for multiple key shares. Validates that provided shares match existing shares.", + "Validate and update reshared_at timestamp for multiple key shares. Validates that provided shares match existing shares. Requires commit-reveal authentication.", security: [{ oauthAuth: [] }], request: { body: { @@ -65,6 +65,20 @@ registry.registerPath({ msg: "Share is not valid", }, }, + INVALID_REQUEST: { + value: { + success: false, + code: "INVALID_REQUEST", + msg: "cr_session_id and cr_signature are required", + }, + }, + INVALID_SIGNATURE: { + value: { + success: false, + code: "INVALID_SIGNATURE", + msg: "Invalid signature", + }, + }, }, }, }, @@ -78,7 +92,7 @@ registry.registerPath({ }, }, 404: { - description: "Not found - User, wallet or key share not found", + description: "Not found - User, wallet, key share or session not found", content: { "application/json": { schema: ErrorResponseSchema, @@ -104,6 +118,47 @@ registry.registerPath({ msg: "Key share not found for curve_type: ed25519", }, }, + SESSION_NOT_FOUND: { + value: { + success: false, + code: "SESSION_NOT_FOUND", + msg: "Session not found", + }, + }, + }, + }, + }, + }, + 409: { + description: "Conflict - API already called for this session", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + API_ALREADY_CALLED: { + value: { + success: false, + code: "API_ALREADY_CALLED", + msg: 'API "reshare" has already been called for this session', + }, + }, + }, + }, + }, + }, + 410: { + description: "Gone - Session has expired", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + SESSION_EXPIRED: { + value: { + success: false, + code: "SESSION_EXPIRED", + msg: "Session has expired", + }, + }, }, }, }, diff --git a/key_share_node/server/src/routes/key_share_v2/reshare_register.ts b/key_share_node/server/src/routes/key_share_v2/reshare_register.ts index 12b3fa5d3..4c8d09a24 100644 --- a/key_share_node/server/src/routes/key_share_v2/reshare_register.ts +++ b/key_share_node/server/src/routes/key_share_v2/reshare_register.ts @@ -21,10 +21,10 @@ import { registry.registerPath({ method: "post", path: "/keyshare/v2/reshare/register", - tags: ["Key Share v2"], + tags: ["Key Share v2", "Commit-Reveal"], summary: "Register key shares during reshare (new node)", description: - "Register key shares for an existing user when a new node joins during reshare. Unlike /register, the user must already exist.", + "Register key shares for an existing user when a new node joins during reshare. Unlike /register, the user must already exist. Requires commit-reveal authentication.", security: [{ oauthAuth: [] }], request: { body: { @@ -65,6 +65,55 @@ registry.registerPath({ msg: "Share is not valid", }, }, + INVALID_REQUEST: { + value: { + success: false, + code: "INVALID_REQUEST", + msg: "cr_session_id and cr_signature are required", + }, + }, + INVALID_SIGNATURE: { + value: { + success: false, + code: "INVALID_SIGNATURE", + msg: "Invalid signature", + }, + }, + }, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing bearer token", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: "Not found - Session not found", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { + SESSION_NOT_FOUND: { + value: { + success: false, + code: "SESSION_NOT_FOUND", + msg: "Session not found", + }, + }, + }, + }, + }, + }, + 409: { + description: "Conflict - Duplicate public key or API already called", + content: { + "application/json": { + schema: ErrorResponseSchema, + examples: { DUPLICATE_PUBLIC_KEY: { value: { success: false, @@ -72,15 +121,31 @@ registry.registerPath({ msg: "Duplicate public key for curve_type: secp256k1", }, }, + API_ALREADY_CALLED: { + value: { + success: false, + code: "API_ALREADY_CALLED", + msg: 'API "reshare_register" has already been called for this session', + }, + }, }, }, }, }, - 401: { - description: "Unauthorized - Invalid or missing bearer token", + 410: { + description: "Gone - Session has expired", content: { "application/json": { schema: ErrorResponseSchema, + examples: { + SESSION_EXPIRED: { + value: { + success: false, + code: "SESSION_EXPIRED", + msg: "Session has expired", + }, + }, + }, }, }, }, From 000ec05980b9849bf7940fa0ec3c7320ac021941 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 28 Jan 2026 18:06:01 +0900 Subject: [PATCH 02/25] o --- common/oko_types/src/commit_reveal/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/common/oko_types/src/commit_reveal/index.ts b/common/oko_types/src/commit_reveal/index.ts index 460526e21..8342aa41e 100644 --- a/common/oko_types/src/commit_reveal/index.ts +++ b/common/oko_types/src/commit_reveal/index.ts @@ -1,4 +1,9 @@ -export type OperationType = "sign_in" | "sign_up" | "reshare"; +export type OperationType = + | "sign_in" + | "sign_up" + | "sign_in_reshare" + | "register_reshare" + | "add_ed25519"; export type SessionState = "COMMITTED" | "COMPLETED" | "EXPIRED"; export interface CommitRevealSession { From 7c463de7cf1459f29ba2b46a43369287bafebe3a Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 28 Jan 2026 18:09:54 +0900 Subject: [PATCH 03/25] oko_api: add allowed_apis config --- .../server/src/commit_reveal/allowed_apis.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 backend/oko_api/server/src/commit_reveal/allowed_apis.ts 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..5b9d2f6ac --- /dev/null +++ b/backend/oko_api/server/src/commit_reveal/allowed_apis.ts @@ -0,0 +1,39 @@ +import type { OperationType } from "@oko-wallet/oko-types/commit_reveal"; + +/** + * Defines which API names are allowed for each operation type. + * Each operation type has a specific set of APIs that can be called. + */ +export const ALLOWED_APIS: Record = { + sign_in: ["signin"], + sign_up: ["keygen"], + sign_in_reshare: ["signin", "reshare"], // signin first, then reshare + register_reshare: [], // Not used in oko_api + add_ed25519: ["keygen_ed25519"], +}; + +/** + * Defines which API names mark the completion of an operation. + * When a final API is successfully called, the session state changes to COMPLETED. + */ +export const FINAL_APIS: Record = { + sign_in: ["signin"], + sign_up: ["keygen"], + sign_in_reshare: ["reshare"], + register_reshare: [], // Not used in oko_api + add_ed25519: ["keygen_ed25519"], +}; + +export function isApiAllowed( + operationType: OperationType, + apiName: string, +): boolean { + return ALLOWED_APIS[operationType].includes(apiName); +} + +export function isFinalApi( + operationType: OperationType, + apiName: string, +): boolean { + return FINAL_APIS[operationType].includes(apiName); +} From ac82ac28d312c44e26c9097995d442661d910493 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 28 Jan 2026 18:36:13 +0900 Subject: [PATCH 04/25] oko_api: add commit endpoint for commit-reveal scheme --- .../src/routes/tss_v2/commit_reveal/commit.ts | 173 ++++++++++++++++++ .../oko_api/server/src/routes/tss_v2/index.ts | 3 + backend/oko_api_error_codes/src/index.ts | 5 + backend/openapi/src/tss/commit_reveal.ts | 112 ++++++++++++ backend/openapi/src/tss/index.ts | 1 + common/oko_types/src/api_response/index.ts | 5 + 6 files changed, 299 insertions(+) create mode 100644 backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.ts create mode 100644 backend/openapi/src/tss/commit_reveal.ts diff --git a/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.ts b/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.ts new file mode 100644 index 000000000..2cbf5cff9 --- /dev/null +++ b/backend/oko_api/server/src/routes/tss_v2/commit_reveal/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-reveal/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/index.ts b/backend/oko_api/server/src/routes/tss_v2/index.ts index 767a5b144..65bcd4ec2 100644 --- a/backend/oko_api/server/src/routes/tss_v2/index.ts +++ b/backend/oko_api/server/src/routes/tss_v2/index.ts @@ -27,10 +27,13 @@ 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_reveal/commit"; export function makeTSSRouterV2() { const router = Router(); + router.post("/commit-reveal/commit", commitRevealCommit); + router.post("/keygen", oauthMiddleware, tssActivateMiddleware, keygenV2); router.post( 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..8b0ec88d6 --- /dev/null +++ b/backend/openapi/src/tss/commit_reveal.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import { registry } from "@oko-wallet/oko-api-openapi"; + +export const OperationTypeSchema = z + .enum([ + "sign_in", + "sign_up", + "sign_in_reshare", + "register_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/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"; From f10f878ddfba34cf2a4f87d7030178fb944073ca Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Wed, 28 Jan 2026 19:07:52 +0900 Subject: [PATCH 05/25] o --- common/oko_types/src/commit_reveal/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/oko_types/src/commit_reveal/index.ts b/common/oko_types/src/commit_reveal/index.ts index 8342aa41e..c0277599e 100644 --- a/common/oko_types/src/commit_reveal/index.ts +++ b/common/oko_types/src/commit_reveal/index.ts @@ -4,7 +4,8 @@ export type OperationType = | "sign_in_reshare" | "register_reshare" | "add_ed25519"; -export type SessionState = "COMMITTED" | "COMPLETED" | "EXPIRED"; + +export type SessionState = "COMMITTED" | "COMPLETED"; export interface CommitRevealSession { session_id: string; From 54818ab9d75a83ab490c4d1f0422b28fa6d65fd4 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 11:08:27 +0900 Subject: [PATCH 06/25] merge OKO-574 --- .../src/openapi/schema/commit_reveal.ts | 2 +- key_share_node/server/src/routes/index.ts | 1 - .../src/routes/key_share_v2/commit.test.ts | 426 ++++++++++++++++++ .../server/src/routes/key_share_v2/commit.ts | 174 +++++++ .../src/routes/key_share_v2/e2e.test.ts | 42 +- .../server/src/routes/key_share_v2/index.ts | 3 + 6 files changed, 622 insertions(+), 26 deletions(-) create mode 100644 key_share_node/server/src/routes/key_share_v2/commit.test.ts create mode 100644 key_share_node/server/src/routes/key_share_v2/commit.ts diff --git a/key_share_node/server/src/openapi/schema/commit_reveal.ts b/key_share_node/server/src/openapi/schema/commit_reveal.ts index e0b6241e6..efcb2ccc1 100644 --- a/key_share_node/server/src/openapi/schema/commit_reveal.ts +++ b/key_share_node/server/src/openapi/schema/commit_reveal.ts @@ -12,7 +12,7 @@ export const operationTypeSchema = z ]) .describe("Operation type for commit-reveal session"); -// POST /commit-reveal/v2/commit +// POST /keyshare/v2/commit export const CommitRequestBodySchema = registry.register( "CommitRequestBody", diff --git a/key_share_node/server/src/routes/index.ts b/key_share_node/server/src/routes/index.ts index 2bda3d933..c242d0a69 100644 --- a/key_share_node/server/src/routes/index.ts +++ b/key_share_node/server/src/routes/index.ts @@ -13,7 +13,6 @@ export function setRoutes(app: Express) { const pgDumpRouter = makePgDumpRouter(); app.use("/pg_dump/v1", pgDumpRouter); - // NOTE: A new architecture where each io handler is mapped to a single file. const keyshareV2Router = makeKeyshareV2Router(); app.use("/keyshare/v2", keyshareV2Router); 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 new file mode 100644 index 000000000..f066e022d --- /dev/null +++ b/key_share_node/server/src/routes/key_share_v2/commit.test.ts @@ -0,0 +1,426 @@ +import request from "supertest"; +import express from "express"; +import { Pool } from "pg"; +import dayjs from "dayjs"; +import { Bytes } from "@oko-wallet/bytes"; +import { randomBytes } from "node:crypto"; +import { v4 as uuidv4 } from "uuid"; + +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 { commit } 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, +}; + +function generateRandomHex(bytes: number): string { + return randomBytes(bytes).toString("hex"); +} + +describe("commit_reveal_commit_test", () => { + let pool: Pool; + let app: express.Application; + + beforeAll(async () => { + const config = testPgConfig; + const createPostgresRes = await connectPG({ + 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("/keyshare/v2/commit", commit); + + app.locals = { + db: pool, + encryptionSecret: "temp_enc_secret", + serverKeypair: mockServerKeypair, + telegram_bot_token: "temp_telegram_bot_token", + is_db_backup_checked: false, + launch_time: dayjs().toISOString(), + git_hash: "", + version: "", + } satisfies ServerState; + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + }); + + afterAll(async () => { + await pool.end(); + }); + + const testEndpoint = "/keyshare/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 register_reshare operation", async () => { + const body = { + ...createValidBody(), + operation_type: "register_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("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 (DB error)", 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 (DB error)", 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); + }); + }); + + 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 "2_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 "2_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(), + ); + }); + }); +}); diff --git a/key_share_node/server/src/routes/key_share_v2/commit.ts b/key_share_node/server/src/routes/key_share_v2/commit.ts new file mode 100644 index 000000000..5c8426e8b --- /dev/null +++ b/key_share_node/server/src/routes/key_share_v2/commit.ts @@ -0,0 +1,174 @@ +import { type Request, type Response } from "express"; +import { Bytes } from "@oko-wallet/bytes"; +import type { KSNodeApiResponse } from "@oko-wallet/ksn-interface/response"; +import { + signMessage, + convertEddsaSignatureToBytes, +} from "@oko-wallet/crypto-js/node/ecdhe"; +import { createCommitRevealSession } from "@oko-wallet/ksn-pg-interface/commit_reveal"; + +import { registry } from "@oko-wallet-ksn-server/openapi/doc"; +import { + CommitRequestBodySchema, + CommitSuccessResponseSchema, + ErrorResponseSchema, + type CommitRequestBody, + type CommitResponseData, +} from "@oko-wallet-ksn-server/openapi/schema"; +import { ErrorCodeMap } from "@oko-wallet-ksn-server/error"; +import type { ServerState } from "@oko-wallet-ksn-server/state"; + +const SESSION_EXPIRY_MINUTES = 5; + +registry.registerPath({ + method: "post", + path: "/keyshare/v2/commit", + tags: ["Commit-Reveal"], + 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 commit( + 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(ErrorCodeMap.SESSION_ALREADY_EXISTS).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.serverKeypair.publicKey.toHex(); + const signResult = signMessage(nodePubkeyHex, state.serverKeypair.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/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 eaf06468f..468ac584b 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 @@ -2,7 +2,7 @@ * E2E Integration Tests for Commit-Reveal + KeyShare v2 APIs * * Tests the full flow: - * 1. Commit phase - POST /commit-reveal/v2/commit + * 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 */ @@ -21,18 +21,16 @@ 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 { makeCommitRevealRouter } from "@oko-wallet-ksn-server/routes/commit_reveal"; 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 { commitRevealMiddleware } from "@oko-wallet-ksn-server/middlewares"; import { keyshareV2Register } from "./register"; import { getKeysharesV2 } from "./get_key_shares"; import { registerKeyshareEd25519 } from "./ed25519"; import { keyshareV2Reshare } from "./reshare"; +import { commit } from "./commit"; // Mock server keypair (must match the one used by the commit endpoint) const serverPrivateKeyRes = Bytes.fromHexString( @@ -60,9 +58,7 @@ const TEST_ENC_SECRET = "test_enc_secret"; // Generate random 64-byte share (hex) function generateRandomShare(): string { - const arr = Array.from({ length: 64 }, () => - Math.floor(Math.random() * 256), - ); + const arr = Array.from({ length: 64 }, () => Math.floor(Math.random() * 256)); return Buffer.from(arr).toString("hex"); } @@ -194,9 +190,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { version: "", } satisfies ServerState; - // Mount commit-reveal router - const commitRevealRouter = makeCommitRevealRouter(); - app.use("/commit-reveal/v2", commitRevealRouter); + app.post("/keyshare/v2/commit", commit); // Mount keyshare v2 routes with commit-reveal middleware + mock OAuth app.post( @@ -239,7 +233,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Step 1: Commit const commitResponse = await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: ctx.sessionId, operation_type: ctx.operationType, @@ -328,7 +322,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Commit await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: ctx.sessionId, operation_type: ctx.operationType, @@ -408,7 +402,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Commit for sign_up await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: signUpCtx.sessionId, operation_type: signUpCtx.operationType, @@ -462,7 +456,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Commit for sign_in await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: signInCtx.sessionId, operation_type: signInCtx.operationType, @@ -524,7 +518,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { ); await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: signUpCtx.sessionId, operation_type: signUpCtx.operationType, @@ -577,7 +571,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Commit for sign_in_reshare await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: reshareCtx.sessionId, operation_type: reshareCtx.operationType, @@ -667,7 +661,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { ); await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: signUpCtx.sessionId, operation_type: signUpCtx.operationType, @@ -720,7 +714,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Commit for add_ed25519 await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: addEd25519Ctx.sessionId, operation_type: addEd25519Ctx.operationType, @@ -765,7 +759,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Commit await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: ctx.sessionId, operation_type: ctx.operationType, @@ -806,7 +800,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Commit await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: ctx.sessionId, operation_type: ctx.operationType, @@ -817,7 +811,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Manually expire the session await pool.query( - 'UPDATE "2_commit_reveal_sessions" SET expires_at = NOW() - INTERVAL \'1 minute\' WHERE session_id = $1', + "UPDATE \"2_commit_reveal_sessions\" SET expires_at = NOW() - INTERVAL '1 minute' WHERE session_id = $1", [ctx.sessionId], ); @@ -896,7 +890,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Commit with sign_in operation await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: ctx.sessionId, operation_type: ctx.operationType, @@ -944,7 +938,7 @@ describe("e2e_commit_reveal_keyshare_test", () => { // Commit await request(app) - .post("/commit-reveal/v2/commit") + .post("/keyshare/v2/commit") .send({ session_id: ctx.sessionId, operation_type: ctx.operationType, diff --git a/key_share_node/server/src/routes/key_share_v2/index.ts b/key_share_node/server/src/routes/key_share_v2/index.ts index 4e716dde0..52c23a79f 100644 --- a/key_share_node/server/src/routes/key_share_v2/index.ts +++ b/key_share_node/server/src/routes/key_share_v2/index.ts @@ -10,6 +10,7 @@ import { keyshareV2Register } from "./register"; import { registerKeyshareEd25519 } from "./ed25519"; import { keyshareV2Reshare } from "./reshare"; import { keyshareV2ReshareRegister } from "./reshare_register"; +import { commit } from "./commit"; export function makeKeyshareV2Router() { const router = Router(); @@ -51,5 +52,7 @@ export function makeKeyshareV2Router() { keyshareV2ReshareRegister, ); + router.post("/commit", commit); + return router; } From 35ead8acc513d85e5ff0bd641f3398e2d525637c Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 11:49:24 +0900 Subject: [PATCH 07/25] oko_api: add commit-reveal middleware --- .../server/src/middleware/commit_reveal.ts | 251 ++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 backend/oko_api/server/src/middleware/commit_reveal.ts 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..55b2a7a31 --- /dev/null +++ b/backend/oko_api/server/src/middleware/commit_reveal.ts @@ -0,0 +1,251 @@ +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 { ErrorCodeMap } from "@oko-wallet/oko-api-error-codes"; +import { + isApiAllowed, + isFinalApi, +} from "@oko-wallet-api/commit_reveal/allowed_apis"; +import type { ServerState } from "@oko-wallet/oko-api-server-state"; + +export interface CommitRevealBody { + cr_session_id: string; + cr_signature: string; // 128 chars hex (64 bytes) + auth_type?: string; +} + +export function commitRevealMiddleware(apiName: string) { + 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 ?? "google"; + 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 = `${nodePubkeyHex}${cr_session_id}${authType}${idToken}${session.operation_type}${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(); + }; +} From 7f0227d994d80ab99bfad8e842ecedc66f5272f7 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 12:36:52 +0900 Subject: [PATCH 08/25] oko_api: apply commit-reveal middleware --- .../oko_api/server/src/commit_reveal/allowed_apis.ts | 2 -- backend/oko_api/server/src/routes/tss_v2/index.ts | 12 +++++++++++- backend/openapi/src/tss/commit_reveal.ts | 8 +------- common/oko_types/src/commit_reveal/index.ts | 1 - 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/backend/oko_api/server/src/commit_reveal/allowed_apis.ts b/backend/oko_api/server/src/commit_reveal/allowed_apis.ts index 5b9d2f6ac..959c73f2c 100644 --- a/backend/oko_api/server/src/commit_reveal/allowed_apis.ts +++ b/backend/oko_api/server/src/commit_reveal/allowed_apis.ts @@ -8,7 +8,6 @@ export const ALLOWED_APIS: Record = { sign_in: ["signin"], sign_up: ["keygen"], sign_in_reshare: ["signin", "reshare"], // signin first, then reshare - register_reshare: [], // Not used in oko_api add_ed25519: ["keygen_ed25519"], }; @@ -20,7 +19,6 @@ export const FINAL_APIS: Record = { sign_in: ["signin"], sign_up: ["keygen"], sign_in_reshare: ["reshare"], - register_reshare: [], // Not used in oko_api add_ed25519: ["keygen_ed25519"], }; 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 65bcd4ec2..157d42d35 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"; @@ -34,11 +35,18 @@ export function makeTSSRouterV2() { router.post("/commit-reveal/commit", commitRevealCommit); - router.post("/keygen", oauthMiddleware, tssActivateMiddleware, keygenV2); + router.post( + "/keygen", + oauthMiddleware, + commitRevealMiddleware("keygen"), + tssActivateMiddleware, + keygenV2, + ); router.post( "/keygen_ed25519", oauthMiddleware, + commitRevealMiddleware("keygen_ed25519"), tssActivateMiddleware, keygenEd25519, ); @@ -154,6 +162,7 @@ export function makeTSSRouterV2() { router.post( "/user/signin", oauthMiddleware, + commitRevealMiddleware("signin"), tssActivateMiddleware, userSignInV2, ); @@ -161,6 +170,7 @@ export function makeTSSRouterV2() { router.post( "/user/reshare", oauthMiddleware, + commitRevealMiddleware("reshare"), tssActivateMiddleware, userReshareV2, ); diff --git a/backend/openapi/src/tss/commit_reveal.ts b/backend/openapi/src/tss/commit_reveal.ts index 8b0ec88d6..0109d0fde 100644 --- a/backend/openapi/src/tss/commit_reveal.ts +++ b/backend/openapi/src/tss/commit_reveal.ts @@ -2,13 +2,7 @@ import { z } from "zod"; import { registry } from "@oko-wallet/oko-api-openapi"; export const OperationTypeSchema = z - .enum([ - "sign_in", - "sign_up", - "sign_in_reshare", - "register_reshare", - "add_ed25519", - ]) + .enum(["sign_in", "sign_up", "sign_in_reshare", "add_ed25519"]) .describe("Operation type for commit-reveal session"); // POST /tss/v2/commit-reveal/commit diff --git a/common/oko_types/src/commit_reveal/index.ts b/common/oko_types/src/commit_reveal/index.ts index c0277599e..adfedbcd7 100644 --- a/common/oko_types/src/commit_reveal/index.ts +++ b/common/oko_types/src/commit_reveal/index.ts @@ -2,7 +2,6 @@ export type OperationType = | "sign_in" | "sign_up" | "sign_in_reshare" - | "register_reshare" | "add_ed25519"; export type SessionState = "COMMITTED" | "COMPLETED"; From 2dd4e61b490ff6351378d052b9f43fdc25def065 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 12:51:01 +0900 Subject: [PATCH 09/25] oko_api: add commit-reveal fields to tss v2 request schemas --- backend/openapi/src/tss/keygen_ed25519.ts | 13 ++-- backend/openapi/src/tss/request.ts | 77 ++++++++++++----------- 2 files changed, 50 insertions(+), 40 deletions(-) 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), ); From bbe82de652fb18ee68c1223ae5c92536cdf87899 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 13:39:04 +0900 Subject: [PATCH 10/25] oko_api: add commit api unit tests --- .../tss_v2/commit_reveal/commit.test.ts | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts diff --git a/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts b/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts new file mode 100644 index 000000000..6552a5249 --- /dev/null +++ b/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts @@ -0,0 +1,228 @@ +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_reveal_commit_success_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-reveal/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-reveal/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(), + ); + }); + }); +}); From bceced5d8a5c7272feaca9ae12e6050db9145623 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 14:29:10 +0900 Subject: [PATCH 11/25] oko_api: add commit api unit tests for error cases --- .../tss_v2/commit_reveal/commit.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts b/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts index 6552a5249..f80f67a1a 100644 --- a/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts +++ b/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts @@ -225,4 +225,186 @@ describe("commit_reveal_commit_success_test", () => { ); }); }); + + 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); + }); + }); }); From b7f61577cd3cf3b5a05e1a1fc4431cb7056d34a5 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 14:49:17 +0900 Subject: [PATCH 12/25] oko_api: add middleware unit tests for basic validation --- .../src/middleware/commit_reveal.test.ts | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 backend/oko_api/server/src/middleware/commit_reveal.test.ts 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..66ffae2d9 --- /dev/null +++ b/backend/oko_api/server/src/middleware/commit_reveal.test.ts @@ -0,0 +1,309 @@ +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 { 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_basic_validation_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"); + }); + }); +}); From 2ab3579b40156c2e8b27c377fce885071f8ba1e1 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 15:19:24 +0900 Subject: [PATCH 13/25] o --- .../src/middleware/commit_reveal.test.ts | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) diff --git a/backend/oko_api/server/src/middleware/commit_reveal.test.ts b/backend/oko_api/server/src/middleware/commit_reveal.test.ts index 66ffae2d9..0357f117d 100644 --- a/backend/oko_api/server/src/middleware/commit_reveal.test.ts +++ b/backend/oko_api/server/src/middleware/commit_reveal.test.ts @@ -6,6 +6,12 @@ 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"; @@ -306,4 +312,268 @@ describe("commit_reveal_middleware_basic_validation_test", () => { 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); + }); + }); }); From ce0cb3f11a6604a5ed86db17eda889c9649468cb Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 15:43:40 +0900 Subject: [PATCH 14/25] o --- .../server/src/commit_reveal/allowed_apis.ts | 27 ++++++++----------- .../server/src/middleware/commit_reveal.ts | 3 ++- common/oko_types/src/commit_reveal/index.ts | 2 ++ .../ksn_interface/src/commit_reveal.ts | 8 ++++++ .../server/src/commit_reveal/allowed_apis.ts | 24 +++++++---------- .../server/src/middlewares/commit_reveal.ts | 2 +- 6 files changed, 34 insertions(+), 32 deletions(-) diff --git a/backend/oko_api/server/src/commit_reveal/allowed_apis.ts b/backend/oko_api/server/src/commit_reveal/allowed_apis.ts index 959c73f2c..da7d3f9a2 100644 --- a/backend/oko_api/server/src/commit_reveal/allowed_apis.ts +++ b/backend/oko_api/server/src/commit_reveal/allowed_apis.ts @@ -1,21 +1,16 @@ -import type { OperationType } from "@oko-wallet/oko-types/commit_reveal"; +import type { + OperationType, + ApiName, +} from "@oko-wallet/oko-types/commit_reveal"; -/** - * Defines which API names are allowed for each operation type. - * Each operation type has a specific set of APIs that can be called. - */ -export const ALLOWED_APIS: Record = { +export const ALLOWED_APIS: Record = { sign_in: ["signin"], sign_up: ["keygen"], - sign_in_reshare: ["signin", "reshare"], // signin first, then reshare + sign_in_reshare: ["signin", "reshare"], add_ed25519: ["keygen_ed25519"], }; -/** - * Defines which API names mark the completion of an operation. - * When a final API is successfully called, the session state changes to COMPLETED. - */ -export const FINAL_APIS: Record = { +export const FINAL_APIS: Record = { sign_in: ["signin"], sign_up: ["keygen"], sign_in_reshare: ["reshare"], @@ -24,14 +19,14 @@ export const FINAL_APIS: Record = { export function isApiAllowed( operationType: OperationType, - apiName: string, + apiName: ApiName, ): boolean { - return ALLOWED_APIS[operationType].includes(apiName); + return (ALLOWED_APIS[operationType]).includes(apiName); } export function isFinalApi( operationType: OperationType, - apiName: string, + apiName: ApiName, ): boolean { - return FINAL_APIS[operationType].includes(apiName); + return (FINAL_APIS[operationType]).includes(apiName); } diff --git a/backend/oko_api/server/src/middleware/commit_reveal.ts b/backend/oko_api/server/src/middleware/commit_reveal.ts index 55b2a7a31..1d5ee73b0 100644 --- a/backend/oko_api/server/src/middleware/commit_reveal.ts +++ b/backend/oko_api/server/src/middleware/commit_reveal.ts @@ -10,6 +10,7 @@ import { } from "@oko-wallet/oko-pg-interface/commit_reveal"; import { ErrorCodeMap } from "@oko-wallet/oko-api-error-codes"; +import type { ApiName } from "@oko-wallet/oko-types/commit_reveal"; import { isApiAllowed, isFinalApi, @@ -22,7 +23,7 @@ export interface CommitRevealBody { auth_type?: string; } -export function commitRevealMiddleware(apiName: 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; diff --git a/common/oko_types/src/commit_reveal/index.ts b/common/oko_types/src/commit_reveal/index.ts index adfedbcd7..8a9015342 100644 --- a/common/oko_types/src/commit_reveal/index.ts +++ b/common/oko_types/src/commit_reveal/index.ts @@ -4,6 +4,8 @@ export type OperationType = | "sign_in_reshare" | "add_ed25519"; +export type ApiName = "signin" | "keygen" | "reshare" | "keygen_ed25519"; + export type SessionState = "COMMITTED" | "COMPLETED"; export interface CommitRevealSession { diff --git a/key_share_node/ksn_interface/src/commit_reveal.ts b/key_share_node/ksn_interface/src/commit_reveal.ts index fbadd3c4d..227ff1873 100644 --- a/key_share_node/ksn_interface/src/commit_reveal.ts +++ b/key_share_node/ksn_interface/src/commit_reveal.ts @@ -4,6 +4,14 @@ export type OperationType = | "sign_in_reshare" | "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..2cbc712e0 100644 --- a/key_share_node/server/src/commit_reveal/allowed_apis.ts +++ b/key_share_node/server/src/commit_reveal/allowed_apis.ts @@ -1,13 +1,9 @@ -import type { OperationType } from "@oko-wallet/ksn-interface/commit_reveal"; +import type { + OperationType, + ApiName, +} from "@oko-wallet/ksn-interface/commit_reveal"; -export type ApiName = - | "get_key_shares" - | "register" - | "reshare" - | "reshare_register" - | "register_ed25519"; - -export const ALLOWED_APIS = { +export const ALLOWED_APIS: Record = { sign_in: ["get_key_shares"], sign_up: ["register"], sign_in_reshare: ["get_key_shares", "reshare"], @@ -15,7 +11,7 @@ export const ALLOWED_APIS = { add_ed25519: ["register_ed25519", "get_key_shares"], }; -export const FINAL_APIS = { +export const FINAL_APIS: Record = { sign_in: ["get_key_shares"], sign_up: ["register"], sign_in_reshare: ["reshare"], @@ -25,14 +21,14 @@ export const FINAL_APIS = { export function isApiAllowed( operationType: OperationType, - apiName: string, + apiName: ApiName, ): boolean { - return ALLOWED_APIS[operationType].includes(apiName); + return (ALLOWED_APIS[operationType]).includes(apiName); } export function isFinalApi( operationType: OperationType, - apiName: string, + apiName: ApiName, ): boolean { - return FINAL_APIS[operationType].includes(apiName); + return (FINAL_APIS[operationType]).includes(apiName); } diff --git a/key_share_node/server/src/middlewares/commit_reveal.ts b/key_share_node/server/src/middlewares/commit_reveal.ts index 01fc66c21..003f8852b 100644 --- a/key_share_node/server/src/middlewares/commit_reveal.ts +++ b/key_share_node/server/src/middlewares/commit_reveal.ts @@ -10,10 +10,10 @@ import { } from "@oko-wallet/ksn-pg-interface/commit_reveal"; import { ErrorCodeMap } from "@oko-wallet-ksn-server/error"; +import type { ApiName } from "@oko-wallet/ksn-interface/commit_reveal"; import { isApiAllowed, isFinalApi, - type ApiName, } from "@oko-wallet-ksn-server/commit_reveal"; import type { ServerState } from "@oko-wallet-ksn-server/state"; import { logger } from "@oko-wallet-ksn-server/logger"; From 8217f3ed586da67b85ecc124b4b920071d8b212a Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 16:00:52 +0900 Subject: [PATCH 15/25] o --- backend/oko_api/server/src/middleware/commit_reveal.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/backend/oko_api/server/src/middleware/commit_reveal.test.ts b/backend/oko_api/server/src/middleware/commit_reveal.test.ts index 0357f117d..4836f0260 100644 --- a/backend/oko_api/server/src/middleware/commit_reveal.test.ts +++ b/backend/oko_api/server/src/middleware/commit_reveal.test.ts @@ -111,9 +111,7 @@ describe("commit_reveal_middleware_basic_validation_test", () => { 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)`, + `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, From ece5de9229f7690ff69fa4cd45f0cc4070153fd9 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 16:06:00 +0900 Subject: [PATCH 16/25] o --- backend/oko_api/server/src/middleware/commit_reveal.ts | 4 ++-- key_share_node/server/src/middlewares/commit_reveal.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/oko_api/server/src/middleware/commit_reveal.ts b/backend/oko_api/server/src/middleware/commit_reveal.ts index 1d5ee73b0..beb27daf5 100644 --- a/backend/oko_api/server/src/middleware/commit_reveal.ts +++ b/backend/oko_api/server/src/middleware/commit_reveal.ts @@ -8,9 +8,9 @@ import { updateCommitRevealSessionState, hasCommitRevealApiBeenCalled, } from "@oko-wallet/oko-pg-interface/commit_reveal"; - -import { ErrorCodeMap } from "@oko-wallet/oko-api-error-codes"; import type { ApiName } from "@oko-wallet/oko-types/commit_reveal"; +import { ErrorCodeMap } from "@oko-wallet/oko-api-error-codes"; + import { isApiAllowed, isFinalApi, diff --git a/key_share_node/server/src/middlewares/commit_reveal.ts b/key_share_node/server/src/middlewares/commit_reveal.ts index 003f8852b..4a92bc6de 100644 --- a/key_share_node/server/src/middlewares/commit_reveal.ts +++ b/key_share_node/server/src/middlewares/commit_reveal.ts @@ -8,9 +8,9 @@ import { updateCommitRevealSessionState, hasCommitRevealApiBeenCalled, } from "@oko-wallet/ksn-pg-interface/commit_reveal"; +import type { ApiName } from "@oko-wallet/ksn-interface/commit_reveal"; import { ErrorCodeMap } from "@oko-wallet-ksn-server/error"; -import type { ApiName } from "@oko-wallet/ksn-interface/commit_reveal"; import { isApiAllowed, isFinalApi, From 3f28cc6b90700d727910b0cb14322f80884081e6 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 16:11:09 +0900 Subject: [PATCH 17/25] o --- .../src/routes/commit_reveal/commit.test.ts | 427 ------------------ .../server/src/routes/commit_reveal/commit.ts | 174 ------- .../server/src/routes/commit_reveal/index.ts | 11 - key_share_node/server/src/routes/index.ts | 4 - 4 files changed, 616 deletions(-) delete mode 100644 key_share_node/server/src/routes/commit_reveal/commit.test.ts delete mode 100644 key_share_node/server/src/routes/commit_reveal/commit.ts delete mode 100644 key_share_node/server/src/routes/commit_reveal/index.ts diff --git a/key_share_node/server/src/routes/commit_reveal/commit.test.ts b/key_share_node/server/src/routes/commit_reveal/commit.test.ts deleted file mode 100644 index 4f14a23a6..000000000 --- a/key_share_node/server/src/routes/commit_reveal/commit.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -import request from "supertest"; -import express from "express"; -import { Pool } from "pg"; -import dayjs from "dayjs"; -import { Bytes } from "@oko-wallet/bytes"; -import { randomBytes } from "node:crypto"; -import { v4 as uuidv4 } from "uuid"; - -import { connectPG, resetPgDatabase } from "@oko-wallet-ksn-server/database"; -import { testPgConfig } from "@oko-wallet-ksn-server/database/test_config"; -import { makeCommitRevealRouter } from "."; -import type { ServerState } from "@oko-wallet-ksn-server/state"; - -// 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, -}; - -function generateRandomHex(bytes: number): string { - return randomBytes(bytes).toString("hex"); -} - -describe("commit_reveal_commit_test", () => { - let pool: Pool; - let app: express.Application; - - beforeAll(async () => { - const config = testPgConfig; - const createPostgresRes = await connectPG({ - 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()); - - const commitRevealRouter = makeCommitRevealRouter(); - app.use("/commit-reveal/v2", commitRevealRouter); - - app.locals = { - db: pool, - encryptionSecret: "temp_enc_secret", - serverKeypair: mockServerKeypair, - telegram_bot_token: "temp_telegram_bot_token", - is_db_backup_checked: false, - launch_time: dayjs().toISOString(), - git_hash: "", - version: "", - } satisfies ServerState; - }); - - beforeEach(async () => { - await resetPgDatabase(pool); - }); - - afterAll(async () => { - await pool.end(); - }); - - const testEndpoint = "/commit-reveal/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 register_reshare operation", async () => { - const body = { - ...createValidBody(), - operation_type: "register_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("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 (DB error)", 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 (DB error)", 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); - }); - }); - - 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 "2_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 "2_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(), - ); - }); - }); -}); diff --git a/key_share_node/server/src/routes/commit_reveal/commit.ts b/key_share_node/server/src/routes/commit_reveal/commit.ts deleted file mode 100644 index ac64d362b..000000000 --- a/key_share_node/server/src/routes/commit_reveal/commit.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { type Request, type Response } from "express"; -import { Bytes } from "@oko-wallet/bytes"; -import type { KSNodeApiResponse } from "@oko-wallet/ksn-interface/response"; -import { - signMessage, - convertEddsaSignatureToBytes, -} from "@oko-wallet/crypto-js/node/ecdhe"; -import { createCommitRevealSession } from "@oko-wallet/ksn-pg-interface/commit_reveal"; - -import { registry } from "@oko-wallet-ksn-server/openapi/doc"; -import { - CommitRequestBodySchema, - CommitSuccessResponseSchema, - ErrorResponseSchema, - type CommitRequestBody, - type CommitResponseData, -} from "@oko-wallet-ksn-server/openapi/schema"; -import { ErrorCodeMap } from "@oko-wallet-ksn-server/error"; -import type { ServerState } from "@oko-wallet-ksn-server/state"; - -const SESSION_EXPIRY_MINUTES = 5; - -registry.registerPath({ - method: "post", - path: "/commit-reveal/v2/commit", - tags: ["Commit-Reveal"], - 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(ErrorCodeMap.SESSION_ALREADY_EXISTS).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.serverKeypair.publicKey.toHex(); - const signResult = signMessage(nodePubkeyHex, state.serverKeypair.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/key_share_node/server/src/routes/commit_reveal/index.ts b/key_share_node/server/src/routes/commit_reveal/index.ts deleted file mode 100644 index 68a2ad797..000000000 --- a/key_share_node/server/src/routes/commit_reveal/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Router } from "express"; - -import { commitRevealCommit } from "./commit"; - -export function makeCommitRevealRouter() { - const router = Router(); - - router.post("/commit", commitRevealCommit); - - return router; -} diff --git a/key_share_node/server/src/routes/index.ts b/key_share_node/server/src/routes/index.ts index c242d0a69..b0d458e56 100644 --- a/key_share_node/server/src/routes/index.ts +++ b/key_share_node/server/src/routes/index.ts @@ -4,7 +4,6 @@ import { makePgDumpRouter } from "./pg_dump"; import { addStatusRoutes } from "./status"; import { makeKeyshareRouter } from "./key_share/v1"; import { makeKeyshareV2Router } from "./key_share_v2"; -import { makeCommitRevealRouter } from "./commit_reveal"; export function setRoutes(app: Express) { const keyshareRouter = makeKeyshareRouter(); @@ -16,8 +15,5 @@ export function setRoutes(app: Express) { const keyshareV2Router = makeKeyshareV2Router(); app.use("/keyshare/v2", keyshareV2Router); - const commitRevealRouter = makeCommitRevealRouter(); - app.use("/commit-reveal/v2", commitRevealRouter); - addStatusRoutes(app); } From 757fd78214e5b9949e6663fc14ba83ecb7b44286 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 16:26:46 +0900 Subject: [PATCH 18/25] o --- .../src/middleware/commit_reveal.test.ts | 592 ++++++++++++++++++ 1 file changed, 592 insertions(+) diff --git a/backend/oko_api/server/src/middleware/commit_reveal.test.ts b/backend/oko_api/server/src/middleware/commit_reveal.test.ts index 4836f0260..f70ec3c26 100644 --- a/backend/oko_api/server/src/middleware/commit_reveal.test.ts +++ b/backend/oko_api/server/src/middleware/commit_reveal.test.ts @@ -575,3 +575,595 @@ describe("commit_reveal_middleware_basic_validation_test", () => { }); }); }); + +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"); + }); + }); +}); From ff21c196e7649738ee5baf39c71c9dea078d4f6a Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 16:59:06 +0900 Subject: [PATCH 19/25] oko_api: add e2e test --- .../server/src/routes/tss_v2/e2e.test.ts | 499 ++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 backend/oko_api/server/src/routes/tss_v2/e2e.test.ts 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..634261eba --- /dev/null +++ b/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts @@ -0,0 +1,499 @@ +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_reveal/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_e2e_success_flows", () => { + 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-reveal/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-reveal/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-reveal/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-reveal/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-reveal/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-reveal/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); + }); + }); +}); From 5ab7d8db788363cc949996f5687f74c45a7deb22 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 17:58:15 +0900 Subject: [PATCH 20/25] oko_api: add e2e test --- .../server/src/routes/tss_v2/e2e.test.ts | 287 ++++++++++++++++++ 1 file changed, 287 insertions(+) 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 index 634261eba..765f74357 100644 --- a/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts +++ b/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts @@ -497,3 +497,290 @@ describe("tss_v2_e2e_success_flows", () => { }); }); }); + +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-reveal/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-reveal/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-reveal/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-reveal/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-reveal/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"); + }); + }); +}); From c106b4181f5173796fba842c62cb32f8aab746c0 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Thu, 29 Jan 2026 18:17:58 +0900 Subject: [PATCH 21/25] o --- .../tss_v2/{commit_reveal => }/commit.test.ts | 4 ++-- .../tss_v2/{commit_reveal => }/commit.ts | 2 +- .../server/src/routes/tss_v2/e2e.test.ts | 24 +++++++++---------- .../oko_api/server/src/routes/tss_v2/index.ts | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) rename backend/oko_api/server/src/routes/tss_v2/{commit_reveal => }/commit.test.ts (99%) rename backend/oko_api/server/src/routes/tss_v2/{commit_reveal => }/commit.ts (99%) diff --git a/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts b/backend/oko_api/server/src/routes/tss_v2/commit.test.ts similarity index 99% rename from backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts rename to backend/oko_api/server/src/routes/tss_v2/commit.test.ts index f80f67a1a..fa6c3e4a4 100644 --- a/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.test.ts +++ b/backend/oko_api/server/src/routes/tss_v2/commit.test.ts @@ -63,7 +63,7 @@ describe("commit_reveal_commit_success_test", () => { app = express(); app.use(express.json()); - app.post("/tss/v2/commit-reveal/commit", commitRevealCommit); + app.post("/tss/v2/commit", commitRevealCommit); app.locals.db = pool; app.locals.server_keypair = mockServerKeypair; @@ -78,7 +78,7 @@ describe("commit_reveal_commit_success_test", () => { await pool.end(); }); - const testEndpoint = "/tss/v2/commit-reveal/commit"; + const testEndpoint = "/tss/v2/commit"; const createValidBody = () => ({ session_id: uuidv4(), diff --git a/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.ts b/backend/oko_api/server/src/routes/tss_v2/commit.ts similarity index 99% rename from backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.ts rename to backend/oko_api/server/src/routes/tss_v2/commit.ts index 2cbf5cff9..4b39fd136 100644 --- a/backend/oko_api/server/src/routes/tss_v2/commit_reveal/commit.ts +++ b/backend/oko_api/server/src/routes/tss_v2/commit.ts @@ -21,7 +21,7 @@ const SESSION_EXPIRY_MINUTES = 5; registry.registerPath({ method: "post", - path: "/tss/v2/commit-reveal/commit", + path: "/tss/v2/commit", tags: ["TSS"], summary: "Create a commit-reveal session", description: 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 index 765f74357..81a032086 100644 --- a/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts +++ b/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts @@ -15,7 +15,7 @@ import { import { testPgConfig } from "@oko-wallet-api/database/test_config"; import { resetPgDatabase } from "@oko-wallet-api/testing/database"; -import { commitRevealCommit } from "./commit_reveal/commit"; +import { commitRevealCommit } from "./commit"; import { commitRevealMiddleware } from "@oko-wallet-api/middleware/commit_reveal"; // Mock keypair for testing @@ -71,7 +71,7 @@ describe("tss_v2_e2e_success_flows", () => { app.use(express.json()); // Commit endpoint (no middleware) - app.post("/tss/v2/commit-reveal/commit", commitRevealCommit); + app.post("/tss/v2/commit", commitRevealCommit); // Protected endpoints with commit-reveal middleware // Mock handlers that simulate successful API calls @@ -185,7 +185,7 @@ describe("tss_v2_e2e_success_flows", () => { // Step 1: Commit const commitResponse = await request(app) - .post("/tss/v2/commit-reveal/commit") + .post("/tss/v2/commit") .send({ session_id: sessionId, operation_type: "sign_up", @@ -249,7 +249,7 @@ describe("tss_v2_e2e_success_flows", () => { // Commit await request(app) - .post("/tss/v2/commit-reveal/commit") + .post("/tss/v2/commit") .send({ session_id: sessionId, operation_type: "sign_up", @@ -317,7 +317,7 @@ describe("tss_v2_e2e_success_flows", () => { // Commit const commitResponse = await request(app) - .post("/tss/v2/commit-reveal/commit") + .post("/tss/v2/commit") .send({ session_id: sessionId, operation_type: "sign_in", @@ -373,7 +373,7 @@ describe("tss_v2_e2e_success_flows", () => { // Commit with sign_in_reshare operation await request(app) - .post("/tss/v2/commit-reveal/commit") + .post("/tss/v2/commit") .send({ session_id: sessionId, operation_type: "sign_in_reshare", @@ -456,7 +456,7 @@ describe("tss_v2_e2e_success_flows", () => { // Commit await request(app) - .post("/tss/v2/commit-reveal/commit") + .post("/tss/v2/commit") .send({ session_id: sessionId, operation_type: "add_ed25519", @@ -523,7 +523,7 @@ describe("tss_v2_e2e_error_scenarios", () => { app = express(); app.use(express.json()); - app.post("/tss/v2/commit-reveal/commit", commitRevealCommit); + 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" } }); @@ -580,7 +580,7 @@ describe("tss_v2_e2e_error_scenarios", () => { // Commit await request(app) - .post("/tss/v2/commit-reveal/commit") + .post("/tss/v2/commit") .send({ session_id: sessionId, operation_type: "sign_up", @@ -627,7 +627,7 @@ describe("tss_v2_e2e_error_scenarios", () => { // Commit await request(app) - .post("/tss/v2/commit-reveal/commit") + .post("/tss/v2/commit") .send({ session_id: sessionId, operation_type: "sign_up", @@ -698,7 +698,7 @@ describe("tss_v2_e2e_error_scenarios", () => { // Commit with sign_in operation await request(app) - .post("/tss/v2/commit-reveal/commit") + .post("/tss/v2/commit") .send({ session_id: sessionId, operation_type: "sign_in", // sign_in operation @@ -749,7 +749,7 @@ describe("tss_v2_e2e_error_scenarios", () => { // Commit with original token hash await request(app) - .post("/tss/v2/commit-reveal/commit") + .post("/tss/v2/commit") .send({ session_id: sessionId, operation_type: "sign_up", 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 157d42d35..0a15e20ec 100644 --- a/backend/oko_api/server/src/routes/tss_v2/index.ts +++ b/backend/oko_api/server/src/routes/tss_v2/index.ts @@ -28,12 +28,12 @@ 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_reveal/commit"; +import { commitRevealCommit } from "./commit"; export function makeTSSRouterV2() { const router = Router(); - router.post("/commit-reveal/commit", commitRevealCommit); + router.post("/commit", commitRevealCommit); router.post( "/keygen", From 1e016f0196e83807f56a4452d0ee54fe8191801f Mon Sep 17 00:00:00 2001 From: Elden Park Date: Thu, 29 Jan 2026 14:29:42 -0800 Subject: [PATCH 22/25] o --- key_share_node/server/src/commit_reveal/allowed_apis.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 2cbc712e0..1684eeb8d 100644 --- a/key_share_node/server/src/commit_reveal/allowed_apis.ts +++ b/key_share_node/server/src/commit_reveal/allowed_apis.ts @@ -3,7 +3,7 @@ import type { ApiName, } from "@oko-wallet/ksn-interface/commit_reveal"; -export const ALLOWED_APIS: Record = { +export const ALLOWED_APIS = { sign_in: ["get_key_shares"], sign_up: ["register"], sign_in_reshare: ["get_key_shares", "reshare"], @@ -11,7 +11,7 @@ export const ALLOWED_APIS: Record = { add_ed25519: ["register_ed25519", "get_key_shares"], }; -export const FINAL_APIS: Record = { +export const FINAL_APIS = { sign_in: ["get_key_shares"], sign_up: ["register"], sign_in_reshare: ["reshare"], @@ -23,12 +23,12 @@ export function isApiAllowed( operationType: OperationType, apiName: ApiName, ): boolean { - return (ALLOWED_APIS[operationType]).includes(apiName); + return ALLOWED_APIS[operationType].includes(apiName); } export function isFinalApi( operationType: OperationType, apiName: ApiName, ): boolean { - return (FINAL_APIS[operationType]).includes(apiName); + return FINAL_APIS[operationType].includes(apiName); } From 4734837cfddd5ff562c96e08768c15e148859526 Mon Sep 17 00:00:00 2001 From: Elden Park Date: Thu, 29 Jan 2026 14:52:16 -0800 Subject: [PATCH 23/25] o --- .../server/src/middlewares/commit_reveal.ts | 53 ++++++++++++++++--- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/key_share_node/server/src/middlewares/commit_reveal.ts b/key_share_node/server/src/middlewares/commit_reveal.ts index 4a92bc6de..4d6a4a415 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 } from "@oko-wallet/ksn-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, -} 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) @@ -137,7 +139,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 +171,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 +260,32 @@ 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 + ); +} From 89e0b1a2d20e6af06dc3a0598f5e4deffc78f092 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Fri, 30 Jan 2026 11:42:48 +0900 Subject: [PATCH 24/25] o --- .../server/src/commit_reveal/allowed_apis.ts | 8 ++-- .../oko_api/server/src/commit_reveal/index.ts | 1 + .../server/src/middleware/commit_reveal.ts | 45 +++++++++++++++++-- .../server/src/middlewares/commit_reveal.ts | 4 +- 4 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 backend/oko_api/server/src/commit_reveal/index.ts diff --git a/backend/oko_api/server/src/commit_reveal/allowed_apis.ts b/backend/oko_api/server/src/commit_reveal/allowed_apis.ts index da7d3f9a2..96eb2f096 100644 --- a/backend/oko_api/server/src/commit_reveal/allowed_apis.ts +++ b/backend/oko_api/server/src/commit_reveal/allowed_apis.ts @@ -3,14 +3,14 @@ import type { ApiName, } from "@oko-wallet/oko-types/commit_reveal"; -export const ALLOWED_APIS: Record = { +export const ALLOWED_APIS = { sign_in: ["signin"], sign_up: ["keygen"], sign_in_reshare: ["signin", "reshare"], add_ed25519: ["keygen_ed25519"], }; -export const FINAL_APIS: Record = { +export const FINAL_APIS = { sign_in: ["signin"], sign_up: ["keygen"], sign_in_reshare: ["reshare"], @@ -21,12 +21,12 @@ export function isApiAllowed( operationType: OperationType, apiName: ApiName, ): boolean { - return (ALLOWED_APIS[operationType]).includes(apiName); + return ALLOWED_APIS[operationType].includes(apiName); } export function isFinalApi( operationType: OperationType, apiName: ApiName, ): boolean { - return (FINAL_APIS[operationType]).includes(apiName); + 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.ts b/backend/oko_api/server/src/middleware/commit_reveal.ts index beb27daf5..31ba5ddb2 100644 --- a/backend/oko_api/server/src/middleware/commit_reveal.ts +++ b/backend/oko_api/server/src/middleware/commit_reveal.ts @@ -8,15 +8,17 @@ import { updateCommitRevealSessionState, hasCommitRevealApiBeenCalled, } from "@oko-wallet/oko-pg-interface/commit_reveal"; -import type { ApiName } from "@oko-wallet/oko-types/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/allowed_apis"; +} 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) @@ -135,7 +137,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,7 +171,14 @@ export function commitRevealMiddleware(apiName: ApiName) { // Verify signature: message = node_pubkey + session_id + auth_type + id_token + operation_type + api_name const nodePubkeyHex = state.server_keypair.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, @@ -250,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/middlewares/commit_reveal.ts b/key_share_node/server/src/middlewares/commit_reveal.ts index 4d6a4a415..b3b379e42 100644 --- a/key_share_node/server/src/middlewares/commit_reveal.ts +++ b/key_share_node/server/src/middlewares/commit_reveal.ts @@ -41,7 +41,6 @@ export function commitRevealMiddleware(apiName: ApiName) { return; } - // Get session from DB const sessionResult = await getCommitRevealSessionBySessionId( state.db, cr_session_id, @@ -270,8 +269,7 @@ export interface SigMessageArgs { apiName: ApiName; } -// message = node_pubkey + session_id + auth_type + -// id_token + operation_type + api_name +// message = node_pubkey + session_id + auth_type + id_token + operation_type + api_name function makeSigMessage({ nodePubkeyHex, cr_session_id, From 7e3f7f44b9a2f08c8b97649714461a11fa0cd064 Mon Sep 17 00:00:00 2001 From: chihunmanse Date: Fri, 30 Jan 2026 12:00:14 +0900 Subject: [PATCH 25/25] o --- .../server/src/middleware/commit_reveal.test.ts | 2 +- .../server/src/routes/tss_v2/commit.test.ts | 2 +- .../oko_api/server/src/routes/tss_v2/e2e.test.ts | 2 +- .../server/src/middlewares/commit_reveal.test.ts | 10 +++++----- .../server/src/routes/key_share_v2/commit.test.ts | 2 +- .../server/src/routes/key_share_v2/e2e.test.ts | 14 +++----------- 6 files changed, 12 insertions(+), 20 deletions(-) diff --git a/backend/oko_api/server/src/middleware/commit_reveal.test.ts b/backend/oko_api/server/src/middleware/commit_reveal.test.ts index f70ec3c26..fe900c95c 100644 --- a/backend/oko_api/server/src/middleware/commit_reveal.test.ts +++ b/backend/oko_api/server/src/middleware/commit_reveal.test.ts @@ -44,7 +44,7 @@ function generateRandomHex(bytes: number): string { return randomBytes(bytes).toString("hex"); } -describe("commit_reveal_middleware_basic_validation_test", () => { +describe("commit_reveal_middleware_test", () => { let pool: Pool; let app: express.Application; 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 index fa6c3e4a4..0c2a2779d 100644 --- a/backend/oko_api/server/src/routes/tss_v2/commit.test.ts +++ b/backend/oko_api/server/src/routes/tss_v2/commit.test.ts @@ -38,7 +38,7 @@ function generateRandomHex(bytes: number): string { return randomBytes(bytes).toString("hex"); } -describe("commit_reveal_commit_success_test", () => { +describe("commit_route_test", () => { let pool: Pool; let app: express.Application; 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 index 81a032086..04ab69133 100644 --- a/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts +++ b/backend/oko_api/server/src/routes/tss_v2/e2e.test.ts @@ -45,7 +45,7 @@ function generateRandomHex(bytes: number): string { return randomBytes(bytes).toString("hex"); } -describe("tss_v2_e2e_success_flows", () => { +describe("tss_v2_commit_reveal_e2e_test", () => { let pool: Pool; let app: express.Application; 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/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;