diff --git a/Cargo.toml b/Cargo.toml index 249d12aa1..bf2749650 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crypto/teddsa/frost_core", "crypto/teddsa/frost_ed25519_keplr", "crypto/teddsa/frost_rerandomized", + "crypto/teddsa/teddsa_addon/addon", "crypto/teddsa/teddsa_keplr_mock", "crypto/teddsa/teddsa_wasm_mock/wasm", "crypto/teddsa/teddsa_keplr_addon_mock/addon", @@ -32,5 +33,8 @@ proptest = "1.0" rand = "0.8" rand_chacha = "0.3" rand_core = "0.6" -serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } +serde = { version = "1.0", default-features = false, features = [ + "derive", + "alloc", +] } serde_json = "1.0" diff --git a/backend/oko_api/error_codes/src/index.ts b/backend/oko_api/error_codes/src/index.ts index a8cf09e6e..eeb4a74f8 100644 --- a/backend/oko_api/error_codes/src/index.ts +++ b/backend/oko_api/error_codes/src/index.ts @@ -40,6 +40,7 @@ export const ErrorCodeMap: Record = { KS_NODE_ALREADY_ACTIVE: 400, IMAGE_UPLOAD_FAILED: 500, INVALID_PUBLIC_KEY: 400, + INVALID_WALLET_TYPE: 400, REFERRAL_NOT_FOUND: 404, UNKNOWN_ERROR: 500, }; diff --git a/backend/openapi/src/tss/index.ts b/backend/openapi/src/tss/index.ts index aad6af32a..105ea881c 100644 --- a/backend/openapi/src/tss/index.ts +++ b/backend/openapi/src/tss/index.ts @@ -1,3 +1,4 @@ +export * from "./keygen_ed25519"; export * from "./presign"; export * from "./request"; export * from "./sign"; diff --git a/backend/openapi/src/tss/keygen_ed25519.ts b/backend/openapi/src/tss/keygen_ed25519.ts new file mode 100644 index 000000000..81b7e8490 --- /dev/null +++ b/backend/openapi/src/tss/keygen_ed25519.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; + +import { registry } from "../registry"; + +const TeddsaKeygenOutputSchema = registry.register( + "TeddsaKeygenOutput", + z.object({ + key_package: z + .array(z.number()) + .openapi({ description: "FROST KeyPackage bytes (contains secret share)" }), + public_key_package: z + .array(z.number()) + .openapi({ description: "Public key package bytes (shared by all participants)" }), + identifier: z + .array(z.number()) + .openapi({ description: "Participant identifier bytes" }), + public_key: z + .array(z.number()) + .openapi({ description: "Ed25519 public key bytes (32 bytes)" }), + }), +); + +export const KeygenEd25519RequestSchema = registry.register( + "TssKeygenEd25519Request", + z.object({ + keygen_2: TeddsaKeygenOutputSchema.openapi({ + description: "Server's keygen output from centralized key generation", + }), + }), +); diff --git a/backend/tss_api/package.json b/backend/tss_api/package.json index 0122745fd..d9e1f95d1 100644 --- a/backend/tss_api/package.json +++ b/backend/tss_api/package.json @@ -21,6 +21,8 @@ "@oko-wallet/social-login-api": "workspace:*", "@oko-wallet/stdlib-js": "^0.0.2-rc.44", "@oko-wallet/tecdsa-interface": "0.0.2-alpha.22", + "@oko-wallet/teddsa-addon": "workspace:*", + "@oko-wallet/teddsa-interface": "workspace:*", "cors": "^2.8.5", "dayjs": "^1.11.18", "dotenv": "^16.4.5", diff --git a/backend/tss_api/src/api/keplr_auth/index.ts b/backend/tss_api/src/api/keplr_auth/index.ts index 796a11775..7d20e21ec 100644 --- a/backend/tss_api/src/api/keplr_auth/index.ts +++ b/backend/tss_api/src/api/keplr_auth/index.ts @@ -21,6 +21,7 @@ export function generateUserToken( const payload: UserTokenPayload = { email: args.email, wallet_id: args.wallet_id, + wallet_id_ed25519: args.wallet_id_ed25519, type: "user", }; diff --git a/backend/tss_api/src/api/keygen/index.ts b/backend/tss_api/src/api/keygen/index.ts index 370a86434..5475c0909 100644 --- a/backend/tss_api/src/api/keygen/index.ts +++ b/backend/tss_api/src/api/keygen/index.ts @@ -127,6 +127,7 @@ export async function runKeygen( publicKeyBytes, activeKSNodes, auth_type, + "secp256k1", ); if (checkKeyshareFromKSNodesRes.success === false) { return checkKeyshareFromKSNodesRes; diff --git a/backend/tss_api/src/api/keygen_ed25519/index.test.ts b/backend/tss_api/src/api/keygen_ed25519/index.test.ts new file mode 100644 index 000000000..d902def34 --- /dev/null +++ b/backend/tss_api/src/api/keygen_ed25519/index.test.ts @@ -0,0 +1,343 @@ +import { Pool } from "pg"; +import type { KeygenEd25519Request } from "@oko-wallet/oko-types/tss"; +import type { TeddsaKeygenOutput } from "@oko-wallet/teddsa-interface"; +import { Participant } from "@oko-wallet/teddsa-interface"; +import { runKeygenCentralizedEd25519 } from "@oko-wallet/teddsa-addon/src/server"; +import { createPgConn } from "@oko-wallet/postgres-lib"; +import { insertKSNode } from "@oko-wallet/oko-pg-interface/ks_nodes"; +import { createUser } from "@oko-wallet/oko-pg-interface/ewallet_users"; +import { createWallet } from "@oko-wallet/oko-pg-interface/ewallet_wallets"; +import { insertKeyShareNodeMeta } from "@oko-wallet/oko-pg-interface/key_share_node_meta"; +import { encryptDataAsync } from "@oko-wallet/crypto-js/node"; +import type { WalletStatus } from "@oko-wallet/oko-types/wallets"; + +import { resetPgDatabase } from "@oko-wallet-tss-api/testing/database"; +import { testPgConfig } from "@oko-wallet-tss-api/database/test_config"; +import { runKeygenEd25519 } from "@oko-wallet-tss-api/api/keygen_ed25519"; +import { TEMP_ENC_SECRET } from "@oko-wallet-tss-api/api/utils"; + +const SSS_THRESHOLD = 2; +const TEST_EMAIL = "keygen-ed25519-test@test.com"; +const TEST_JWT_CONFIG = { + secret: "test-jwt-secret-for-keygen-ed25519", + expires_in: "1h", +}; + +async function setUpKSNodes(pool: Pool): Promise { + const ksNodeNames = ["ksNode1", "ksNode2"]; + const ksNodeIds = []; + const createKSNodesRes = await Promise.all( + ksNodeNames.map((ksNodeName) => + insertKSNode(pool, ksNodeName, `http://test.com/${ksNodeName}`), + ), + ); + for (const res of createKSNodesRes) { + if (res.success === false) { + throw new Error("Failed to create ks nodes"); + } + ksNodeIds.push(res.data.node_id); + } + return ksNodeIds; +} + +async function setUpKeyShareNodeMeta(pool: Pool): Promise { + await insertKeyShareNodeMeta(pool, { + sss_threshold: SSS_THRESHOLD, + }); +} + +function generateKeygenRequest( + serverKeygenOutput: TeddsaKeygenOutput, + email: string = TEST_EMAIL, +): KeygenEd25519Request { + return { + auth_type: "google", + email, + keygen_2: serverKeygenOutput, + }; +} + +describe("Ed25519 Keygen", () => { + let pool: Pool; + + 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; + }); + + afterAll(async () => { + await pool.end(); + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + }); + + describe("runKeygenEd25519", () => { + it("should create new user and wallet for new email", async () => { + await setUpKSNodes(pool); + await setUpKeyShareNodeMeta(pool); + + const keygenResult = runKeygenCentralizedEd25519(); + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + const request = generateKeygenRequest(serverKeygenOutput); + + const result = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request, + TEMP_ENC_SECRET, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.token).toBeDefined(); + expect(result.data.user.email).toBe(TEST_EMAIL); + expect(result.data.user.wallet_id).toBeDefined(); + expect(result.data.user.public_key).toBeDefined(); + expect(result.data.user.public_key.length).toBe(64); // 32 bytes hex = 64 chars + } + }); + + it("should create ed25519 wallet for existing user", async () => { + await setUpKSNodes(pool); + await setUpKeyShareNodeMeta(pool); + + // Create existing user first + const createUserRes = await createUser(pool, TEST_EMAIL, "google"); + expect(createUserRes.success).toBe(true); + + const keygenResult = runKeygenCentralizedEd25519(); + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + const request = generateKeygenRequest(serverKeygenOutput); + + const result = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request, + TEMP_ENC_SECRET, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.user.email).toBe(TEST_EMAIL); + expect(result.data.user.wallet_id).toBeDefined(); + } + }); + + it("should fail if ed25519 wallet already exists for user", async () => { + await setUpKSNodes(pool); + await setUpKeyShareNodeMeta(pool); + + const keygenResult = runKeygenCentralizedEd25519(); + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + // Create first wallet + const request1 = generateKeygenRequest(serverKeygenOutput); + const result1 = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request1, + TEMP_ENC_SECRET, + ); + expect(result1.success).toBe(true); + + // Try to create second wallet with different keys + const keygenResult2 = runKeygenCentralizedEd25519(); + const serverKeygenOutput2 = keygenResult2.keygen_outputs[Participant.P1]; + + const request2 = generateKeygenRequest(serverKeygenOutput2); + const result2 = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request2, + TEMP_ENC_SECRET, + ); + + expect(result2.success).toBe(false); + if (!result2.success) { + expect(result2.code).toBe("WALLET_ALREADY_EXISTS"); + } + }); + + it("should fail if public key is duplicated", async () => { + await setUpKSNodes(pool); + await setUpKeyShareNodeMeta(pool); + + const keygenResult = runKeygenCentralizedEd25519(); + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + // Create wallet for first user + const request1 = generateKeygenRequest(serverKeygenOutput, "user1@test.com"); + const result1 = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request1, + TEMP_ENC_SECRET, + ); + expect(result1.success).toBe(true); + + // Try to create wallet with same public key for different user + const request2 = generateKeygenRequest(serverKeygenOutput, "user2@test.com"); + const result2 = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request2, + TEMP_ENC_SECRET, + ); + + expect(result2.success).toBe(false); + if (!result2.success) { + expect(result2.code).toBe("DUPLICATE_PUBLIC_KEY"); + } + }); + + it("should include optional name in response when provided", async () => { + await setUpKSNodes(pool); + await setUpKeyShareNodeMeta(pool); + + const keygenResult = runKeygenCentralizedEd25519(); + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + const request: KeygenEd25519Request = { + auth_type: "google", + email: TEST_EMAIL, + keygen_2: serverKeygenOutput, + name: "Test User", + }; + + const result = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request, + TEMP_ENC_SECRET, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.user.name).toBe("Test User"); + } + }); + + it("should handle different auth types", async () => { + await setUpKSNodes(pool); + await setUpKeyShareNodeMeta(pool); + + const authTypes = ["google", "auth0"] as const; + + for (let i = 0; i < authTypes.length; i++) { + const keygenResult = runKeygenCentralizedEd25519(); + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + const request: KeygenEd25519Request = { + auth_type: authTypes[i], + email: `authtype-test-${i}@test.com`, + keygen_2: serverKeygenOutput, + }; + + const result = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request, + TEMP_ENC_SECRET, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.user.email).toBe(`authtype-test-${i}@test.com`); + } + } + }); + + it("should generate valid JWT token", async () => { + await setUpKSNodes(pool); + await setUpKeyShareNodeMeta(pool); + + const keygenResult = runKeygenCentralizedEd25519(); + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + const request = generateKeygenRequest(serverKeygenOutput); + + const result = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request, + TEMP_ENC_SECRET, + ); + + expect(result.success).toBe(true); + if (result.success) { + // JWT format: header.payload.signature + const tokenParts = result.data.token.split("."); + expect(tokenParts.length).toBe(3); + } + }); + + it("should work without KS nodes", async () => { + // Don't set up KS nodes, but still need key share meta + await setUpKeyShareNodeMeta(pool); + + const keygenResult = runKeygenCentralizedEd25519(); + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + const request = generateKeygenRequest(serverKeygenOutput); + + const result = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request, + TEMP_ENC_SECRET, + ); + + expect(result.success).toBe(true); + }); + + it("should allow secp256k1 wallet for user with existing ed25519 wallet", async () => { + await setUpKSNodes(pool); + await setUpKeyShareNodeMeta(pool); + + // Create ed25519 wallet first + const keygenResult = runKeygenCentralizedEd25519(); + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + const request = generateKeygenRequest(serverKeygenOutput); + const ed25519Result = await runKeygenEd25519( + pool, + TEST_JWT_CONFIG, + request, + TEMP_ENC_SECRET, + ); + expect(ed25519Result.success).toBe(true); + + // Create secp256k1 wallet for same user should succeed (different curve) + if (ed25519Result.success) { + const createWalletRes = await createWallet(pool, { + user_id: ed25519Result.data.user.wallet_id.split("-")[0], // This would be wrong in practice + curve_type: "secp256k1", + public_key: Buffer.from("03" + "00".repeat(32), "hex"), + enc_tss_share: Buffer.from("encrypted-share", "utf-8"), + sss_threshold: SSS_THRESHOLD, + status: "ACTIVE" as WalletStatus, + }); + // This test is just to verify isolation between curve types + // In production, the user_id would come from the database + } + }); + }); +}); diff --git a/backend/tss_api/src/api/keygen_ed25519/index.ts b/backend/tss_api/src/api/keygen_ed25519/index.ts new file mode 100644 index 000000000..d2842c336 --- /dev/null +++ b/backend/tss_api/src/api/keygen_ed25519/index.ts @@ -0,0 +1,236 @@ +import { Pool } from "pg"; +import { + createUser, + getUserByEmailAndAuthType, +} from "@oko-wallet/oko-pg-interface/ewallet_users"; +import type { Result } from "@oko-wallet/stdlib-js"; +import { encryptDataAsync } from "@oko-wallet/crypto-js/node"; +import { Bytes, type Bytes32 } from "@oko-wallet/bytes"; +import { type WalletStatus, type Wallet } from "@oko-wallet/oko-types/wallets"; +import type { KeygenEd25519Request } from "@oko-wallet/oko-types/tss"; +import type { SignInResponse, User } from "@oko-wallet/oko-types/user"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import { + createWallet, + getActiveWalletByUserIdAndCurveType, + getWalletByPublicKey, +} from "@oko-wallet/oko-pg-interface/ewallet_wallets"; +import { + createWalletKSNodes, + getActiveKSNodes, +} from "@oko-wallet/oko-pg-interface/ks_nodes"; +import { getKeyShareNodeMeta } from "@oko-wallet/oko-pg-interface/key_share_node_meta"; + +import { generateUserToken } from "@oko-wallet-tss-api/api/keplr_auth"; + +export async function runKeygenEd25519( + db: Pool, + jwtConfig: { + secret: string; + expires_in: string; + }, + keygenRequest: KeygenEd25519Request, + encryptionSecret: string, +): Promise> { + try { + const { auth_type, email, keygen_2, name } = keygenRequest; + + const getUserRes = await getUserByEmailAndAuthType(db, email, auth_type); + if (getUserRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `getUserByEmailAndAuthType error: ${getUserRes.err}`, + }; + } + + let user: User; + if (getUserRes.data !== null) { + user = getUserRes.data; + + const getActiveWalletRes = await getActiveWalletByUserIdAndCurveType( + db, + user.user_id, + "ed25519", + ); + if (getActiveWalletRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `getActiveWalletByUserIdAndCurveType error: ${getActiveWalletRes.err}`, + }; + } + if (getActiveWalletRes.data !== null) { + return { + success: false, + code: "WALLET_ALREADY_EXISTS", + msg: `Ed25519 wallet already exists: ${getActiveWalletRes.data.public_key.toString("hex")}`, + }; + } + } else { + const createUserRes = await createUser(db, email, auth_type); + if (createUserRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `createUser error: ${createUserRes.err}`, + }; + } + user = createUserRes.data; + } + + const publicKeyUint8 = new Uint8Array(keygen_2.public_key); + const publicKeyHex = Buffer.from(publicKeyUint8).toString("hex"); + + const publicKeyRes = Bytes.fromUint8Array(publicKeyUint8, 32); + if (publicKeyRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `publicKeyRes error: ${publicKeyRes.err}`, + }; + } + const publicKeyBytes = publicKeyRes.data; + + const walletByPublicKeyRes = await getWalletByPublicKey( + db, + Buffer.from(publicKeyBytes.toUint8Array()), + ); + if (walletByPublicKeyRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `getWalletByPublicKey error: ${walletByPublicKeyRes.err}`, + }; + } + if (walletByPublicKeyRes.data !== null) { + return { + success: false, + code: "DUPLICATE_PUBLIC_KEY", + msg: `Duplicate public key: ${publicKeyHex}`, + }; + } + + // Ed25519 uses 2-of-2 threshold signature with server, not SSS key share nodes + // Skip checkKeyShareFromKSNodes validation (which expects secp256k1 33-byte keys) + const getActiveKSNodesRes = await getActiveKSNodes(db); + if (getActiveKSNodesRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `getActiveKSNodes error: ${getActiveKSNodesRes.err}`, + }; + } + const activeKSNodes = getActiveKSNodesRes.data; + const ksNodeIds: string[] = activeKSNodes.map((node) => node.node_id); + + const keyPackageJson = JSON.stringify({ + key_package: keygen_2.key_package, + public_key_package: keygen_2.public_key_package, + identifier: keygen_2.identifier, + }); + + const encryptedShare = await encryptDataAsync( + keyPackageJson, + encryptionSecret, + ); + const encryptedShareBuffer = Buffer.from(encryptedShare, "utf-8"); + + const getKeyshareNodeMetaRes = await getKeyShareNodeMeta(db); + if (getKeyshareNodeMetaRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `getKeyShareNodeMeta error: ${getKeyshareNodeMetaRes.err}`, + }; + } + const globalSSSThreshold = getKeyshareNodeMetaRes.data.sss_threshold; + + let wallet: Wallet; + const client = await db.connect(); + try { + await client.query("BEGIN"); + + const createWalletRes = await createWallet(client, { + user_id: user.user_id, + curve_type: "ed25519", + public_key: Buffer.from(publicKeyBytes.toUint8Array()), + enc_tss_share: encryptedShareBuffer, + sss_threshold: globalSSSThreshold, + status: "ACTIVE" as WalletStatus, + }); + if (createWalletRes.success === false) { + throw new Error(`createWallet error: ${createWalletRes.err}`); + } + wallet = createWalletRes.data; + + if (ksNodeIds.length > 0) { + const createWalletKSNodesRes = await createWalletKSNodes( + client, + wallet.wallet_id, + ksNodeIds, + ); + if (createWalletKSNodesRes.success === false) { + throw new Error( + `createWalletKSNodes error: ${createWalletKSNodesRes.err}`, + ); + } + } + + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + return { + success: false, + code: "UNKNOWN_ERROR", + msg: error instanceof Error ? error.message : String(error), + }; + } finally { + client.release(); + } + + // Look up secp256k1 wallet if exists (for existing users) + const secp256k1WalletRes = await getActiveWalletByUserIdAndCurveType( + db, + user.user_id, + "secp256k1", + ); + const secp256k1WalletId = secp256k1WalletRes.success && secp256k1WalletRes.data + ? secp256k1WalletRes.data.wallet_id + : wallet.wallet_id; // Fallback to ed25519 wallet_id if no secp256k1 + + const tokenResult = generateUserToken({ + wallet_id: secp256k1WalletId, + wallet_id_ed25519: wallet.wallet_id, + email: email, + jwt_config: jwtConfig, + }); + + if (!tokenResult.success) { + return { + success: false, + code: "FAILED_TO_GENERATE_TOKEN", + msg: `generateUserToken error: ${tokenResult.err}`, + }; + } + + return { + success: true, + data: { + token: tokenResult.data.token, + user: { + email: email, + wallet_id: wallet.wallet_id, + public_key: publicKeyHex, + name: name ?? null, + }, + }, + }; + } catch (error) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `runKeygenEd25519 error: ${error}`, + }; + } +} diff --git a/backend/tss_api/src/api/ks_node/index.ts b/backend/tss_api/src/api/ks_node/index.ts index d76755e79..25fdc313f 100644 --- a/backend/tss_api/src/api/ks_node/index.ts +++ b/backend/tss_api/src/api/ks_node/index.ts @@ -1,15 +1,17 @@ -import type { Bytes33 } from "@oko-wallet/bytes"; +import type { Bytes32, Bytes33 } from "@oko-wallet/bytes"; import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; -import type { KeyShareNode } from "@oko-wallet-types/tss"; +import type { KeyShareNode } from "@oko-wallet/oko-types/tss"; import type { AuthType } from "@oko-wallet/oko-types/auth"; +import type { CurveType } from "@oko-wallet/ksn-interface/curve_type"; import { requestCheckKeyShare } from "@oko-wallet-tss-api/requests"; export async function checkKeyShareFromKSNodes( userEmail: string, - publicKey: Bytes33, + publicKey: Bytes32 | Bytes33, targetKSNodes: KeyShareNode[], auth_type: AuthType, + curve_type: CurveType, ): Promise> { try { const nodeServerUrls: string[] = []; @@ -27,6 +29,7 @@ export async function checkKeyShareFromKSNodes( userEmail, publicKey, auth_type, + curve_type, ); if (res.success === false) { diff --git a/backend/tss_api/src/api/presign_ed25519/index.ts b/backend/tss_api/src/api/presign_ed25519/index.ts new file mode 100644 index 000000000..262220c62 --- /dev/null +++ b/backend/tss_api/src/api/presign_ed25519/index.ts @@ -0,0 +1,109 @@ +import { createTssSession, createTssStage } from "@oko-wallet/oko-pg-interface/tss"; +import type { + PresignEd25519Request, + PresignEd25519Response, + PresignEd25519StageData, +} from "@oko-wallet/oko-types/tss"; +import { + TssStageType, + PresignEd25519StageStatus, +} from "@oko-wallet/oko-types/tss"; +import type { TeddsaKeygenOutput } from "@oko-wallet/teddsa-interface"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import { Pool } from "pg"; +import { decryptDataAsync } from "@oko-wallet/crypto-js/node"; +import { runSignRound1Ed25519 } from "@oko-wallet/teddsa-addon/src/server"; + +import { validateWalletEmail } from "@oko-wallet-tss-api/api/utils"; + +export async function runPresignEd25519( + db: Pool, + encryptionSecret: string, + request: PresignEd25519Request, +): Promise> { + try { + const { email, wallet_id, customer_id } = request; + + const validateWalletEmailRes = await validateWalletEmail(db, wallet_id, email); + if (validateWalletEmailRes.success === false) { + return { + success: false, + code: "UNAUTHORIZED", + msg: validateWalletEmailRes.err, + }; + } + const wallet = validateWalletEmailRes.data; + + if (wallet.curve_type !== "ed25519") { + return { + success: false, + code: "INVALID_WALLET_TYPE", + msg: `Wallet is not ed25519 type: ${wallet.curve_type}`, + }; + } + + const encryptedShare = wallet.enc_tss_share.toString("utf-8"); + const decryptedShare = await decryptDataAsync( + encryptedShare, + encryptionSecret, + ); + const keygenOutput: TeddsaKeygenOutput = JSON.parse(decryptedShare); + + // Generate nonces and commitments (Round 1 without message) + const round1Result = runSignRound1Ed25519( + new Uint8Array(keygenOutput.key_package), + ); + + // Create TSS session + const sessionRes = await createTssSession(db, { + customer_id, + wallet_id, + }); + if (!sessionRes.success) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to create TSS session: ${sessionRes.err}`, + }; + } + const session = sessionRes.data; + + // Create TSS stage with presign data (nonces stored for later use) + const stageData: PresignEd25519StageData = { + nonces: round1Result.nonces, + identifier: round1Result.identifier, + commitments: round1Result.commitments, + }; + + const stageRes = await createTssStage(db, { + session_id: session.session_id, + stage_type: TssStageType.PRESIGN_ED25519, + stage_status: PresignEd25519StageStatus.COMPLETED, + stage_data: stageData, + }); + if (!stageRes.success) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to create TSS stage: ${stageRes.err}`, + }; + } + + return { + success: true, + data: { + session_id: session.session_id, + commitments_0: { + identifier: round1Result.identifier, + commitments: round1Result.commitments, + }, + }, + }; + } catch (error) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `runPresignEd25519 error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/backend/tss_api/src/api/sign_ed25519/index.test.ts b/backend/tss_api/src/api/sign_ed25519/index.test.ts new file mode 100644 index 000000000..3d37f99f2 --- /dev/null +++ b/backend/tss_api/src/api/sign_ed25519/index.test.ts @@ -0,0 +1,692 @@ +import { jest } from "@jest/globals"; +import { Pool } from "pg"; +import type { + SignEd25519Round1Request, + SignEd25519Round2Request, + SignEd25519AggregateRequest, +} from "@oko-wallet/oko-types/tss"; +import type { TeddsaKeygenOutput } from "@oko-wallet/teddsa-interface"; +import { Participant } from "@oko-wallet/teddsa-interface"; +import { + runKeygenCentralizedEd25519, + runSignRound1Ed25519 as clientRunSignRound1Ed25519, + runSignRound2Ed25519 as clientRunSignRound2Ed25519, + runAggregateEd25519 as clientRunAggregateEd25519, + runVerifyEd25519, +} from "@oko-wallet/teddsa-addon/src/server"; +import { createPgConn } from "@oko-wallet/postgres-lib"; +import type { WalletStatus } from "@oko-wallet/oko-types/wallets"; +import { insertKSNode } from "@oko-wallet/oko-pg-interface/ks_nodes"; +import { createWallet } from "@oko-wallet/oko-pg-interface/ewallet_wallets"; +import { createUser } from "@oko-wallet/oko-pg-interface/ewallet_users"; +import { insertKeyShareNodeMeta } from "@oko-wallet/oko-pg-interface/key_share_node_meta"; +import { insertCustomer } from "@oko-wallet/oko-pg-interface/customers"; +import { encryptDataAsync } from "@oko-wallet/crypto-js/node"; + +import { resetPgDatabase } from "@oko-wallet-tss-api/testing/database"; +import { testPgConfig } from "@oko-wallet-tss-api/database/test_config"; +import { + runSignEd25519Round1, + runSignEd25519Round2, + runSignEd25519Aggregate, +} from "@oko-wallet-tss-api/api/sign_ed25519"; +import { TEMP_ENC_SECRET } from "@oko-wallet-tss-api/api/utils"; + +const SSS_THRESHOLD = 2; +const TEST_EMAIL = "test-ed25519@test.com"; + +interface TestSetupResult { + pool: Pool; + walletId: string; + customerId: string; + clientKeygenOutput: TeddsaKeygenOutput; + serverKeygenOutput: TeddsaKeygenOutput; +} + +async function setUpKSNodes(pool: Pool): Promise { + const ksNodeNames = ["ksNode1", "ksNode2"]; + const ksNodeIds = []; + const createKSNodesRes = await Promise.all( + ksNodeNames.map((ksNodeName) => + insertKSNode(pool, ksNodeName, `http://test.com/${ksNodeName}`), + ), + ); + for (const res of createKSNodesRes) { + if (res.success === false) { + throw new Error("Failed to create ks nodes"); + } + ksNodeIds.push(res.data.node_id); + } + return ksNodeIds; +} + +async function setUpEd25519Wallet(pool: Pool): Promise { + // Generate keys using centralized keygen + const keygenResult = runKeygenCentralizedEd25519(); + const clientKeygenOutput = keygenResult.keygen_outputs[Participant.P0]; + const serverKeygenOutput = keygenResult.keygen_outputs[Participant.P1]; + + // Set up KS nodes and metadata + const ksNodeIds = await setUpKSNodes(pool); + await insertKeyShareNodeMeta(pool, { + sss_threshold: SSS_THRESHOLD, + }); + + // Create customer + const customerId = crypto.randomUUID(); + const insertCustomerRes = await insertCustomer(pool, { + customer_id: customerId, + label: "test-customer", + status: "ACTIVE", + url: null, + logo_url: null, + theme: "dark", + }); + if (insertCustomerRes.success === false) { + throw new Error(`Failed to create customer: ${insertCustomerRes.err}`); + } + + // Create user + const createUserRes = await createUser(pool, TEST_EMAIL, "google"); + if (createUserRes.success === false) { + throw new Error(`Failed to create user: ${createUserRes.err}`); + } + const userId = createUserRes.data.user_id; + + // Encrypt server key package + const serverKeyPackageJson = JSON.stringify(serverKeygenOutput); + const encryptedShare = await encryptDataAsync( + serverKeyPackageJson, + TEMP_ENC_SECRET, + ); + + // Create Ed25519 wallet + const createWalletRes = await createWallet(pool, { + user_id: userId, + curve_type: "ed25519", + public_key: Buffer.from(keygenResult.public_key), + enc_tss_share: Buffer.from(encryptedShare, "utf-8"), + sss_threshold: SSS_THRESHOLD, + status: "ACTIVE" as WalletStatus, + }); + if (createWalletRes.success === false) { + throw new Error(`Failed to create wallet: ${createWalletRes.err}`); + } + const walletId = createWalletRes.data.wallet_id; + + return { + pool, + walletId, + customerId, + clientKeygenOutput, + serverKeygenOutput, + }; +} + +describe("Ed25519 Signing", () => { + let pool: Pool; + + 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; + }); + + afterAll(async () => { + await pool.end(); + }); + + beforeEach(async () => { + await resetPgDatabase(pool); + }); + + describe("runSignEd25519Round1", () => { + it("should generate commitments successfully", async () => { + const { walletId, customerId } = await setUpEd25519Wallet(pool); + const testMessage = new TextEncoder().encode("Test message"); + + const request: SignEd25519Round1Request = { + email: TEST_EMAIL, + wallet_id: walletId, + customer_id: customerId, + msg: [...testMessage], + }; + + const result = await runSignEd25519Round1(pool, TEMP_ENC_SECRET, request); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.session_id).toBeDefined(); + expect(result.data.commitments_0).toBeDefined(); + expect(result.data.commitments_0.identifier).toBeDefined(); + expect(result.data.commitments_0.commitments).toBeDefined(); + expect(Array.isArray(result.data.commitments_0.identifier)).toBe(true); + expect(Array.isArray(result.data.commitments_0.commitments)).toBe(true); + } + }); + + it("should fail with invalid email", async () => { + const { walletId, customerId } = await setUpEd25519Wallet(pool); + const testMessage = new TextEncoder().encode("Test message"); + + const request: SignEd25519Round1Request = { + email: "wrong@test.com", + wallet_id: walletId, + customer_id: customerId, + msg: [...testMessage], + }; + + const result = await runSignEd25519Round1(pool, TEMP_ENC_SECRET, request); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe("UNAUTHORIZED"); + } + }); + + it("should fail with invalid wallet_id", async () => { + const { customerId } = await setUpEd25519Wallet(pool); + const testMessage = new TextEncoder().encode("Test message"); + + const request: SignEd25519Round1Request = { + email: TEST_EMAIL, + wallet_id: "00000000-0000-0000-0000-000000000000", + customer_id: customerId, + msg: [...testMessage], + }; + + const result = await runSignEd25519Round1(pool, TEMP_ENC_SECRET, request); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe("UNAUTHORIZED"); + } + }); + }); + + describe("runSignEd25519Round2", () => { + it("should generate signature share successfully", async () => { + const { walletId, customerId, clientKeygenOutput } = await setUpEd25519Wallet(pool); + const testMessage = new TextEncoder().encode("Test message for Ed25519"); + + // Round 1: Get server commitments + const round1Request: SignEd25519Round1Request = { + email: TEST_EMAIL, + wallet_id: walletId, + customer_id: customerId, + msg: [...testMessage], + }; + const round1Result = await runSignEd25519Round1( + pool, + TEMP_ENC_SECRET, + round1Request, + ); + expect(round1Result.success).toBe(true); + if (!round1Result.success) throw new Error("Round 1 failed"); + + // Client generates their round 1 output + const clientRound1 = clientRunSignRound1Ed25519( + new Uint8Array(clientKeygenOutput.key_package), + ); + + // Round 2: Get server signature share + const round2Request: SignEd25519Round2Request = { + email: TEST_EMAIL, + wallet_id: walletId, + session_id: round1Result.data.session_id, + commitments_1: { + identifier: clientRound1.identifier, + commitments: clientRound1.commitments, + }, + }; + const round2Result = await runSignEd25519Round2( + pool, + TEMP_ENC_SECRET, + round2Request, + ); + + expect(round2Result.success).toBe(true); + if (round2Result.success) { + expect(round2Result.data.signature_share_0).toBeDefined(); + expect(round2Result.data.signature_share_0.identifier).toBeDefined(); + expect(round2Result.data.signature_share_0.signature_share).toBeDefined(); + } + }); + + it("should fail with invalid session_id", async () => { + const { walletId, customerId, clientKeygenOutput } = await setUpEd25519Wallet(pool); + const testMessage = new TextEncoder().encode("Test message"); + + const clientRound1 = clientRunSignRound1Ed25519( + new Uint8Array(clientKeygenOutput.key_package), + ); + + const round2Request: SignEd25519Round2Request = { + email: TEST_EMAIL, + wallet_id: walletId, + session_id: "00000000-0000-0000-0000-000000000000", + commitments_1: { + identifier: clientRound1.identifier, + commitments: clientRound1.commitments, + }, + }; + const result = await runSignEd25519Round2( + pool, + TEMP_ENC_SECRET, + round2Request, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe("INVALID_TSS_SESSION"); + } + }); + + it("should fail when Round2 is called twice (duplicate call prevention)", async () => { + const { walletId, customerId, clientKeygenOutput } = await setUpEd25519Wallet(pool); + const testMessage = new TextEncoder().encode("Test message"); + + // Round 1: Get server commitments + const round1Request: SignEd25519Round1Request = { + email: TEST_EMAIL, + wallet_id: walletId, + customer_id: customerId, + msg: [...testMessage], + }; + const round1Result = await runSignEd25519Round1( + pool, + TEMP_ENC_SECRET, + round1Request, + ); + expect(round1Result.success).toBe(true); + if (!round1Result.success) throw new Error("Round 1 failed"); + + const clientRound1 = clientRunSignRound1Ed25519( + new Uint8Array(clientKeygenOutput.key_package), + ); + + // First Round 2 call - should succeed + const round2Request: SignEd25519Round2Request = { + email: TEST_EMAIL, + wallet_id: walletId, + session_id: round1Result.data.session_id, + commitments_1: { + identifier: clientRound1.identifier, + commitments: clientRound1.commitments, + }, + }; + const round2Result = await runSignEd25519Round2( + pool, + TEMP_ENC_SECRET, + round2Request, + ); + expect(round2Result.success).toBe(true); + + // Second Round 2 call with same session - should fail + const duplicateRound2Result = await runSignEd25519Round2( + pool, + TEMP_ENC_SECRET, + round2Request, + ); + + expect(duplicateRound2Result.success).toBe(false); + if (!duplicateRound2Result.success) { + expect(duplicateRound2Result.code).toBe("INVALID_TSS_SESSION"); + } + }); + + it("should fail when using COMPLETED session for Round2", async () => { + const { walletId, customerId, clientKeygenOutput } = await setUpEd25519Wallet(pool); + const testMessage = new TextEncoder().encode("Test message for signing"); + + // Complete full signing flow first + const round1Res = await runSignEd25519Round1(pool, TEMP_ENC_SECRET, { + email: TEST_EMAIL, + wallet_id: walletId, + customer_id: customerId, + msg: [...testMessage], + }); + expect(round1Res.success).toBe(true); + if (!round1Res.success) throw new Error("Round 1 failed"); + + const clientR1 = clientRunSignRound1Ed25519( + new Uint8Array(clientKeygenOutput.key_package), + ); + + const allCommitments = [ + { identifier: clientR1.identifier, commitments: clientR1.commitments }, + { + identifier: round1Res.data.commitments_0.identifier, + commitments: round1Res.data.commitments_0.commitments, + }, + ].sort((a, b) => (a.identifier[0] ?? 0) - (b.identifier[0] ?? 0)); + + const round2Res = await runSignEd25519Round2(pool, TEMP_ENC_SECRET, { + email: TEST_EMAIL, + wallet_id: walletId, + session_id: round1Res.data.session_id, + commitments_1: { + identifier: clientR1.identifier, + commitments: clientR1.commitments, + }, + }); + expect(round2Res.success).toBe(true); + if (!round2Res.success) throw new Error("Round 2 failed"); + + const clientR2 = clientRunSignRound2Ed25519( + testMessage, + new Uint8Array(clientKeygenOutput.key_package), + new Uint8Array(clientR1.nonces), + allCommitments, + ); + + const allShares = [ + { identifier: clientR2.identifier, signature_share: clientR2.signature_share }, + { + identifier: round2Res.data.signature_share_0.identifier, + signature_share: round2Res.data.signature_share_0.signature_share, + }, + ].sort((a, b) => (a.identifier[0] ?? 0) - (b.identifier[0] ?? 0)); + + // Complete the signing with Aggregate + const aggRes = await runSignEd25519Aggregate(pool, TEMP_ENC_SECRET, { + email: TEST_EMAIL, + wallet_id: walletId, + msg: [...testMessage], + all_commitments: allCommitments, + all_signature_shares: allShares, + }); + expect(aggRes.success).toBe(true); + + // Now try to use the COMPLETED session for Round2 - should fail + const newClientR1 = clientRunSignRound1Ed25519( + new Uint8Array(clientKeygenOutput.key_package), + ); + const replayRound2Res = await runSignEd25519Round2(pool, TEMP_ENC_SECRET, { + email: TEST_EMAIL, + wallet_id: walletId, + session_id: round1Res.data.session_id, // Reusing completed session + commitments_1: { + identifier: newClientR1.identifier, + commitments: newClientR1.commitments, + }, + }); + + expect(replayRound2Res.success).toBe(false); + if (!replayRound2Res.success) { + expect(replayRound2Res.code).toBe("INVALID_TSS_SESSION"); + } + }); + }); + + describe("runSignEd25519Aggregate", () => { + it("should aggregate signatures and produce valid signature", async () => { + const { walletId, customerId, clientKeygenOutput, serverKeygenOutput } = + await setUpEd25519Wallet(pool); + const testMessage = new TextEncoder().encode("Test message for signing"); + + // Round 1: Both parties generate commitments + const round1Request: SignEd25519Round1Request = { + email: TEST_EMAIL, + wallet_id: walletId, + customer_id: customerId, + msg: [...testMessage], + }; + const serverRound1Result = await runSignEd25519Round1( + pool, + TEMP_ENC_SECRET, + round1Request, + ); + expect(serverRound1Result.success).toBe(true); + if (!serverRound1Result.success) throw new Error("Server Round 1 failed"); + + const clientRound1 = clientRunSignRound1Ed25519( + new Uint8Array(clientKeygenOutput.key_package), + ); + + // Collect all commitments (sorted by identifier) + const allCommitments = [ + { + identifier: clientRound1.identifier, + commitments: clientRound1.commitments, + }, + { + identifier: serverRound1Result.data.commitments_0.identifier, + commitments: serverRound1Result.data.commitments_0.commitments, + }, + ].sort((a, b) => (a.identifier[0] ?? 0) - (b.identifier[0] ?? 0)); + + // Round 2: Both parties generate signature shares + const round2Request: SignEd25519Round2Request = { + email: TEST_EMAIL, + wallet_id: walletId, + session_id: serverRound1Result.data.session_id, + commitments_1: { + identifier: clientRound1.identifier, + commitments: clientRound1.commitments, + }, + }; + const serverRound2Result = await runSignEd25519Round2( + pool, + TEMP_ENC_SECRET, + round2Request, + ); + expect(serverRound2Result.success).toBe(true); + if (!serverRound2Result.success) throw new Error("Server Round 2 failed"); + + const clientRound2 = clientRunSignRound2Ed25519( + testMessage, + new Uint8Array(clientKeygenOutput.key_package), + new Uint8Array(clientRound1.nonces), + allCommitments, + ); + + // Collect all signature shares (sorted by identifier) + const allSignatureShares = [ + { + identifier: clientRound2.identifier, + signature_share: clientRound2.signature_share, + }, + { + identifier: serverRound2Result.data.signature_share_0.identifier, + signature_share: + serverRound2Result.data.signature_share_0.signature_share, + }, + ].sort((a, b) => (a.identifier[0] ?? 0) - (b.identifier[0] ?? 0)); + + // Aggregate + const aggregateRequest: SignEd25519AggregateRequest = { + email: TEST_EMAIL, + wallet_id: walletId, + msg: [...testMessage], + all_commitments: allCommitments, + all_signature_shares: allSignatureShares, + }; + const aggregateResult = await runSignEd25519Aggregate( + pool, + TEMP_ENC_SECRET, + aggregateRequest, + ); + + expect(aggregateResult.success).toBe(true); + if (aggregateResult.success) { + expect(aggregateResult.data.signature).toBeDefined(); + expect(aggregateResult.data.signature.length).toBe(64); + + // Verify the signature + const isValid = runVerifyEd25519( + testMessage, + new Uint8Array(aggregateResult.data.signature), + new Uint8Array(clientKeygenOutput.public_key_package), + ); + expect(isValid).toBe(true); + } + }); + + it("should fail with wrong wallet type", async () => { + // Create a secp256k1 wallet instead of ed25519 + await setUpKSNodes(pool); + await insertKeyShareNodeMeta(pool, { sss_threshold: SSS_THRESHOLD }); + + const createUserRes = await createUser(pool, TEST_EMAIL, "google"); + if (!createUserRes.success) throw new Error("Failed to create user"); + + const encryptedShare = await encryptDataAsync( + JSON.stringify({ private_share: "test", public_key: "test" }), + TEMP_ENC_SECRET, + ); + + const createWalletRes = await createWallet(pool, { + user_id: createUserRes.data.user_id, + curve_type: "secp256k1", + public_key: Buffer.from("03" + "00".repeat(32), "hex"), + enc_tss_share: Buffer.from(encryptedShare, "utf-8"), + sss_threshold: SSS_THRESHOLD, + status: "ACTIVE" as WalletStatus, + }); + if (!createWalletRes.success) throw new Error("Failed to create wallet"); + + const aggregateRequest: SignEd25519AggregateRequest = { + email: TEST_EMAIL, + wallet_id: createWalletRes.data.wallet_id, + msg: [1, 2, 3], + all_commitments: [], + all_signature_shares: [], + }; + + const result = await runSignEd25519Aggregate( + pool, + TEMP_ENC_SECRET, + aggregateRequest, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe("INVALID_WALLET_TYPE"); + } + }); + + it("should fail with invalid wallet_id", async () => { + await setUpEd25519Wallet(pool); + + const aggregateRequest: SignEd25519AggregateRequest = { + email: TEST_EMAIL, + wallet_id: "00000000-0000-0000-0000-000000000000", + msg: [1, 2, 3], + all_commitments: [], + all_signature_shares: [], + }; + + const result = await runSignEd25519Aggregate( + pool, + TEMP_ENC_SECRET, + aggregateRequest, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe("UNAUTHORIZED"); + } + }); + }); + + describe("Full signing flow", () => { + it("should complete full signing flow with valid signature verification", async () => { + const { walletId, customerId, clientKeygenOutput } = await setUpEd25519Wallet(pool); + const messages = [ + "Hello, Solana!", + "Transaction data", + "Another message to sign", + ]; + + for (const msgStr of messages) { + const message = new TextEncoder().encode(msgStr); + + // Round 1 + const round1Res = await runSignEd25519Round1(pool, TEMP_ENC_SECRET, { + email: TEST_EMAIL, + wallet_id: walletId, + customer_id: customerId, + msg: [...message], + }); + expect(round1Res.success).toBe(true); + if (!round1Res.success) continue; + + const clientR1 = clientRunSignRound1Ed25519( + new Uint8Array(clientKeygenOutput.key_package), + ); + + const allCommitments = [ + { identifier: clientR1.identifier, commitments: clientR1.commitments }, + { + identifier: round1Res.data.commitments_0.identifier, + commitments: round1Res.data.commitments_0.commitments, + }, + ].sort((a, b) => (a.identifier[0] ?? 0) - (b.identifier[0] ?? 0)); + + // Round 2 + const round2Res = await runSignEd25519Round2(pool, TEMP_ENC_SECRET, { + email: TEST_EMAIL, + wallet_id: walletId, + session_id: round1Res.data.session_id, + commitments_1: { + identifier: clientR1.identifier, + commitments: clientR1.commitments, + }, + }); + expect(round2Res.success).toBe(true); + if (!round2Res.success) continue; + + const clientR2 = clientRunSignRound2Ed25519( + message, + new Uint8Array(clientKeygenOutput.key_package), + new Uint8Array(clientR1.nonces), + allCommitments, + ); + + const allShares = [ + { + identifier: clientR2.identifier, + signature_share: clientR2.signature_share, + }, + { + identifier: round2Res.data.signature_share_0.identifier, + signature_share: round2Res.data.signature_share_0.signature_share, + }, + ].sort((a, b) => (a.identifier[0] ?? 0) - (b.identifier[0] ?? 0)); + + // Aggregate + const aggRes = await runSignEd25519Aggregate(pool, TEMP_ENC_SECRET, { + email: TEST_EMAIL, + wallet_id: walletId, + msg: [...message], + all_commitments: allCommitments, + all_signature_shares: allShares, + }); + expect(aggRes.success).toBe(true); + if (!aggRes.success) continue; + + // Verify + const isValid = runVerifyEd25519( + message, + new Uint8Array(aggRes.data.signature), + new Uint8Array(clientKeygenOutput.public_key_package), + ); + expect(isValid).toBe(true); + } + }); + }); +}); diff --git a/backend/tss_api/src/api/sign_ed25519/index.ts b/backend/tss_api/src/api/sign_ed25519/index.ts new file mode 100644 index 000000000..8ba7c9973 --- /dev/null +++ b/backend/tss_api/src/api/sign_ed25519/index.ts @@ -0,0 +1,461 @@ +import { + createTssSession, + createTssStage, + getTssStageWithSessionData, +} from "@oko-wallet/oko-pg-interface/tss"; +import type { + SignEd25519Round1Request, + SignEd25519Round1Response, + SignEd25519Round2Request, + SignEd25519Round2Response, + SignEd25519AggregateRequest, + SignEd25519AggregateResponse, + SignEd25519StageData, + SignEd25519Request, + SignEd25519Response, + PresignEd25519StageData, +} from "@oko-wallet/oko-types/tss"; +import { + TssStageType, + SignEd25519StageStatus, + PresignEd25519StageStatus, + TssSessionState, +} from "@oko-wallet/oko-types/tss"; +import type { TeddsaKeygenOutput } from "@oko-wallet/teddsa-interface"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import { Pool } from "pg"; +import { decryptDataAsync } from "@oko-wallet/crypto-js/node"; +import { + runSignRound1Ed25519, + runSignRound2Ed25519, + runAggregateEd25519, +} from "@oko-wallet/teddsa-addon/src/server"; + +import { + validateWalletEmail, + validateTssSession, + validateTssStage, + updateTssStageWithSessionState, +} from "@oko-wallet-tss-api/api/utils"; + +export async function runSignEd25519Round1( + db: Pool, + encryptionSecret: string, + request: SignEd25519Round1Request, +): Promise> { + try { + const { email, wallet_id, customer_id, msg } = request; + + const validateWalletEmailRes = await validateWalletEmail(db, wallet_id, email); + if (validateWalletEmailRes.success === false) { + return { + success: false, + code: "UNAUTHORIZED", + msg: validateWalletEmailRes.err, + }; + } + const wallet = validateWalletEmailRes.data; + + if (wallet.curve_type !== "ed25519") { + return { + success: false, + code: "INVALID_WALLET_TYPE", + msg: `Wallet is not ed25519 type: ${wallet.curve_type}`, + }; + } + + const encryptedShare = wallet.enc_tss_share.toString("utf-8"); + const decryptedShare = await decryptDataAsync( + encryptedShare, + encryptionSecret, + ); + const keygenOutput: TeddsaKeygenOutput = JSON.parse(decryptedShare); + + const round1Result = runSignRound1Ed25519( + new Uint8Array(keygenOutput.key_package), + ); + + // Create TSS session + const sessionRes = await createTssSession(db, { + customer_id, + wallet_id, + }); + if (!sessionRes.success) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to create TSS session: ${sessionRes.err}`, + }; + } + const session = sessionRes.data; + + // Create TSS stage with round1 data + const stageData: SignEd25519StageData = { + nonces: round1Result.nonces, + identifier: round1Result.identifier, + commitments: round1Result.commitments, + signature_share: null, + signature: null, + }; + + const stageRes = await createTssStage(db, { + session_id: session.session_id, + stage_type: TssStageType.SIGN_ED25519, + stage_status: SignEd25519StageStatus.ROUND_1, + stage_data: { + ...stageData, + msg, // Store the message for round2 + }, + }); + if (!stageRes.success) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to create TSS stage: ${stageRes.err}`, + }; + } + + return { + success: true, + data: { + session_id: session.session_id, + commitments_0: { + identifier: round1Result.identifier, + commitments: round1Result.commitments, + }, + }, + }; + } catch (error) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `runSignEd25519Round1 error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +export async function runSignEd25519Round2( + db: Pool, + encryptionSecret: string, + request: SignEd25519Round2Request, +): Promise> { + try { + const { email, wallet_id, session_id, commitments_1 } = request; + + const validateWalletEmailRes = await validateWalletEmail(db, wallet_id, email); + if (validateWalletEmailRes.success === false) { + return { + success: false, + code: "UNAUTHORIZED", + msg: validateWalletEmailRes.err, + }; + } + const wallet = validateWalletEmailRes.data; + + if (wallet.curve_type !== "ed25519") { + return { + success: false, + code: "INVALID_WALLET_TYPE", + msg: `Wallet is not ed25519 type: ${wallet.curve_type}`, + }; + } + + // Get stage with session data + const getStageRes = await getTssStageWithSessionData( + db, + session_id, + TssStageType.SIGN_ED25519, + ); + if (getStageRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to get TSS stage: ${getStageRes.err}`, + }; + } + const stage = getStageRes.data; + + // Validate session + if (!validateTssSession(stage, wallet_id)) { + return { + success: false, + code: "INVALID_TSS_SESSION", + msg: "Invalid session state or wallet mismatch", + }; + } + + // Validate stage status + if (!validateTssStage(stage, SignEd25519StageStatus.ROUND_1)) { + return { + success: false, + code: "INVALID_TSS_SESSION", + msg: "Round 1 state not found. Please call round1 first.", + }; + } + + const stageData = stage.stage_data as SignEd25519StageData & { msg: number[] }; + const { nonces, identifier, msg } = stageData; + + if (!nonces || !identifier || !msg) { + return { + success: false, + code: "INVALID_TSS_SESSION", + msg: "Missing round1 data in stage", + }; + } + + const encryptedShare = wallet.enc_tss_share.toString("utf-8"); + const decryptedShare = await decryptDataAsync( + encryptedShare, + encryptionSecret, + ); + const keygenOutput: TeddsaKeygenOutput = JSON.parse(decryptedShare); + + const serverCommitment = { + identifier, + commitments: stageData.commitments!, + }; + + const allCommitments = [serverCommitment, commitments_1]; + allCommitments.sort((a, b) => { + const idA = a.identifier[0] ?? 0; + const idB = b.identifier[0] ?? 0; + return idA - idB; + }); + + const round2Result = runSignRound2Ed25519( + new Uint8Array(msg), + new Uint8Array(keygenOutput.key_package), + new Uint8Array(nonces), + allCommitments, + ); + + // Update stage and session atomically + const updateRes = await updateTssStageWithSessionState( + db, + stage.stage_id, + session_id, + { + stage_status: SignEd25519StageStatus.COMPLETED, + stage_data: { + ...stageData, + signature_share: round2Result.signature_share, + }, + }, + TssSessionState.COMPLETED, + ); + if (!updateRes.success) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to update TSS stage: ${updateRes.err}`, + }; + } + + return { + success: true, + data: { + signature_share_0: { + identifier: round2Result.identifier, + signature_share: round2Result.signature_share, + }, + }, + }; + } catch (error) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `runSignEd25519Round2 error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +// New presign-based sign function +export async function runSignEd25519( + db: Pool, + encryptionSecret: string, + request: SignEd25519Request, +): Promise> { + try { + const { email, wallet_id, session_id, msg, commitments_1 } = request; + + const validateWalletEmailRes = await validateWalletEmail(db, wallet_id, email); + if (validateWalletEmailRes.success === false) { + return { + success: false, + code: "UNAUTHORIZED", + msg: validateWalletEmailRes.err, + }; + } + const wallet = validateWalletEmailRes.data; + + if (wallet.curve_type !== "ed25519") { + return { + success: false, + code: "INVALID_WALLET_TYPE", + msg: `Wallet is not ed25519 type: ${wallet.curve_type}`, + }; + } + + // Get presign stage with session data + const getStageRes = await getTssStageWithSessionData( + db, + session_id, + TssStageType.PRESIGN_ED25519, + ); + if (getStageRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to get TSS stage: ${getStageRes.err}`, + }; + } + const stage = getStageRes.data; + + // Validate session + if (!validateTssSession(stage, wallet_id)) { + return { + success: false, + code: "INVALID_TSS_SESSION", + msg: "Invalid session state or wallet mismatch", + }; + } + + // Validate stage status (must be COMPLETED presign, not USED) + if (!validateTssStage(stage, PresignEd25519StageStatus.COMPLETED)) { + return { + success: false, + code: "INVALID_TSS_SESSION", + msg: "Presign not found or already used. Please call presign_ed25519 first.", + }; + } + + const stageData = stage.stage_data as PresignEd25519StageData; + const { nonces, identifier, commitments } = stageData; + + if (!nonces || !identifier || !commitments) { + return { + success: false, + code: "INVALID_TSS_SESSION", + msg: "Missing presign data in stage", + }; + } + + const encryptedShare = wallet.enc_tss_share.toString("utf-8"); + const decryptedShare = await decryptDataAsync( + encryptedShare, + encryptionSecret, + ); + const keygenOutput: TeddsaKeygenOutput = JSON.parse(decryptedShare); + + const serverCommitment = { + identifier, + commitments, + }; + + const allCommitments = [serverCommitment, commitments_1]; + allCommitments.sort((a, b) => { + const idA = a.identifier[0] ?? 0; + const idB = b.identifier[0] ?? 0; + return idA - idB; + }); + + const round2Result = runSignRound2Ed25519( + new Uint8Array(msg), + new Uint8Array(keygenOutput.key_package), + new Uint8Array(nonces), + allCommitments, + ); + + // Mark presign as USED and complete the session + const updateRes = await updateTssStageWithSessionState( + db, + stage.stage_id, + session_id, + { + stage_status: PresignEd25519StageStatus.USED, + stage_data: stageData, + }, + TssSessionState.COMPLETED, + ); + if (!updateRes.success) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `Failed to update TSS stage: ${updateRes.err}`, + }; + } + + return { + success: true, + data: { + signature_share_0: { + identifier: round2Result.identifier, + signature_share: round2Result.signature_share, + }, + }, + }; + } catch (error) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `runSignEd25519 error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +export async function runSignEd25519Aggregate( + db: Pool, + encryptionSecret: string, + request: SignEd25519AggregateRequest, +): Promise> { + try { + const { email, wallet_id, msg, all_commitments, all_signature_shares } = + request; + + const validateWalletEmailRes = await validateWalletEmail(db, wallet_id, email); + if (validateWalletEmailRes.success === false) { + return { + success: false, + code: "UNAUTHORIZED", + msg: validateWalletEmailRes.err, + }; + } + const wallet = validateWalletEmailRes.data; + + if (wallet.curve_type !== "ed25519") { + return { + success: false, + code: "INVALID_WALLET_TYPE", + msg: `Wallet is not ed25519 type: ${wallet.curve_type}`, + }; + } + + const encryptedShare = wallet.enc_tss_share.toString("utf-8"); + const decryptedShare = await decryptDataAsync( + encryptedShare, + encryptionSecret, + ); + const keygenOutput: TeddsaKeygenOutput = JSON.parse(decryptedShare); + + const aggregateResult = runAggregateEd25519( + new Uint8Array(msg), + all_commitments, + all_signature_shares, + new Uint8Array(keygenOutput.public_key_package), + ); + + return { + success: true, + data: { + signature: aggregateResult.signature, + }, + }; + } catch (error) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `runSignEd25519Aggregate error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/backend/tss_api/src/api/user/index.ts b/backend/tss_api/src/api/user/index.ts index b11828fc4..e0b2b4135 100644 --- a/backend/tss_api/src/api/user/index.ts +++ b/backend/tss_api/src/api/user/index.ts @@ -23,8 +23,8 @@ import type { WalletKSNodeStatus, } from "@oko-wallet/oko-types/tss"; import { getKeyShareNodeMeta } from "@oko-wallet/oko-pg-interface/key_share_node_meta"; -import type { Wallet } from "@oko-wallet-types/wallets"; -import type { NodeNameAndEndpoint } from "@oko-wallet-types/user_key_share"; +import type { Wallet } from "@oko-wallet/oko-types/wallets"; +import type { NodeNameAndEndpoint } from "@oko-wallet/oko-types/user_key_share"; import type { Bytes33 } from "@oko-wallet/bytes"; import { generateUserToken } from "@oko-wallet-tss-api/api/keplr_auth"; @@ -77,8 +77,20 @@ export async function signIn( }; } + // Also look up ed25519 wallet if exists + const ed25519WalletRes = await getActiveWalletByUserIdAndCurveType( + db, + getUserRes.data.user_id, + "ed25519", + ); + // Don't fail if ed25519 wallet doesn't exist, it's optional + const ed25519WalletId = ed25519WalletRes.success && ed25519WalletRes.data + ? ed25519WalletRes.data.wallet_id + : undefined; + const tokenResult = generateUserToken({ wallet_id: walletRes.data.wallet_id, + wallet_id_ed25519: ed25519WalletId, email: getUserRes.data.email, jwt_config, }); @@ -360,6 +372,7 @@ export async function updateWalletKSNodesForReshare( public_key, getKSNodesRes.data, auth_type, + wallet.curve_type, ); if (checkKeyshareFromKSNodesRes.success === false) { return checkKeyshareFromKSNodesRes; diff --git a/backend/tss_api/src/api/wallet_ed25519/index.ts b/backend/tss_api/src/api/wallet_ed25519/index.ts new file mode 100644 index 000000000..af7584618 --- /dev/null +++ b/backend/tss_api/src/api/wallet_ed25519/index.ts @@ -0,0 +1,105 @@ +import { Pool } from "pg"; +import { decryptDataAsync } from "@oko-wallet/crypto-js/node"; +import { getActiveWalletByUserIdAndCurveType } from "@oko-wallet/oko-pg-interface/ewallet_wallets"; +import { getUserByEmailAndAuthType } from "@oko-wallet/oko-pg-interface/ewallet_users"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import type { AuthType } from "@oko-wallet/oko-types/auth"; +import { + Participant, + participantToIdentifier, +} from "@oko-wallet/teddsa-interface"; + +export interface WalletEd25519PublicInfoRequest { + email: string; + auth_type: AuthType; +} + +export interface WalletEd25519PublicInfoResponse { + public_key: string; // hex string + public_key_package: number[]; + identifier: number[]; +} + +/** + * Get Ed25519 wallet public info (for key recovery). + * Returns public_key_package and identifier needed to reconstruct + * the key_package from KS node shares. + */ +export async function getWalletEd25519PublicInfo( + db: Pool, + encryptionSecret: string, + request: WalletEd25519PublicInfoRequest, +): Promise> { + try { + const { email, auth_type } = request; + + // Get user + const getUserRes = await getUserByEmailAndAuthType(db, email, auth_type); + if (getUserRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `getUserByEmailAndAuthType error: ${getUserRes.err}`, + }; + } + if (getUserRes.data === null) { + return { + success: false, + code: "USER_NOT_FOUND", + msg: "User not found", + }; + } + const user = getUserRes.data; + + // Get Ed25519 wallet + const getWalletRes = await getActiveWalletByUserIdAndCurveType( + db, + user.user_id, + "ed25519", + ); + if (getWalletRes.success === false) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `getActiveWalletByUserIdAndCurveType error: ${getWalletRes.err}`, + }; + } + if (getWalletRes.data === null) { + return { + success: false, + code: "WALLET_NOT_FOUND", + msg: "Ed25519 wallet not found", + }; + } + const wallet = getWalletRes.data; + + // Decrypt the stored key package data + const encryptedShare = wallet.enc_tss_share.toString("utf-8"); + const decryptedShare = await decryptDataAsync( + encryptedShare, + encryptionSecret, + ); + const keyPackageData = JSON.parse(decryptedShare) as { + key_package: number[]; + public_key_package: number[]; + identifier: number[]; + }; + + // Return public info for client key recovery + // Server stores keygen_2 (P1), but client needs identifier for P0 + return { + success: true, + data: { + public_key: wallet.public_key.toString("hex"), + public_key_package: keyPackageData.public_key_package, + identifier: participantToIdentifier(Participant.P0), + }, + }; + } catch (error) { + return { + success: false, + code: "UNKNOWN_ERROR", + msg: `getWalletEd25519PublicInfo error: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} diff --git a/backend/tss_api/src/middleware/keplr_auth.ts b/backend/tss_api/src/middleware/keplr_auth.ts index f9febd9c4..e52e1d4bf 100644 --- a/backend/tss_api/src/middleware/keplr_auth.ts +++ b/backend/tss_api/src/middleware/keplr_auth.ts @@ -69,6 +69,7 @@ export async function userJwtMiddleware( res.locals.user = { email: payload.email, wallet_id: payload.wallet_id, + wallet_id_ed25519: payload.wallet_id_ed25519, }; next(); diff --git a/backend/tss_api/src/requests/index.ts b/backend/tss_api/src/requests/index.ts index 7ff6d27e7..97fcbbb9a 100644 --- a/backend/tss_api/src/requests/index.ts +++ b/backend/tss_api/src/requests/index.ts @@ -1,13 +1,15 @@ -import type { Bytes33 } from "@oko-wallet/bytes"; +import type { Bytes32, Bytes33 } from "@oko-wallet/bytes"; import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; import type { CheckKeyShareResponse } from "@oko-wallet/ksn-interface/key_share"; import type { AuthType } from "@oko-wallet/oko-types/auth"; +import type { CurveType } from "@oko-wallet/ksn-interface/curve_type"; export async function requestCheckKeyShare( ksNodeURI: string, userEmail: string, - publicKey: Bytes33, + publicKey: Bytes32 | Bytes33, auth_type: AuthType, + curve_type: CurveType, ) { const res = await fetch(`${ksNodeURI}/keyshare/v1/check`, { method: "POST", @@ -17,6 +19,7 @@ export async function requestCheckKeyShare( body: JSON.stringify({ email: userEmail, auth_type, + curve_type, public_key: publicKey.toHex(), }), }); diff --git a/backend/tss_api/src/routes/index.ts b/backend/tss_api/src/routes/index.ts index 07149b6e5..a3142891c 100644 --- a/backend/tss_api/src/routes/index.ts +++ b/backend/tss_api/src/routes/index.ts @@ -1,9 +1,13 @@ import express from "express"; import { setKeygenRoutes } from "./keygen"; +import { setKeygenEd25519Routes } from "./keygen_ed25519"; import { setTriplesRoutes } from "./triples"; import { setPresignRoutes } from "./presign"; +import { setPresignEd25519Routes } from "./presign_ed25519"; import { setSignRoutes } from "./sign"; +import { setSignEd25519Routes } from "./sign_ed25519"; +import { setWalletEd25519Routes } from "./wallet_ed25519"; import { setUserRoutes } from "./user"; import { setTssSessionRoutes } from "./tss_session"; @@ -11,9 +15,13 @@ export function makeTssRouter() { const router = express.Router(); setKeygenRoutes(router); + setKeygenEd25519Routes(router); setTriplesRoutes(router); setPresignRoutes(router); + setPresignEd25519Routes(router); setSignRoutes(router); + setSignEd25519Routes(router); + setWalletEd25519Routes(router); setUserRoutes(router); setTssSessionRoutes(router); diff --git a/backend/tss_api/src/routes/keygen_ed25519.ts b/backend/tss_api/src/routes/keygen_ed25519.ts new file mode 100644 index 000000000..0e634935e --- /dev/null +++ b/backend/tss_api/src/routes/keygen_ed25519.ts @@ -0,0 +1,132 @@ +import type { Response, Router } from "express"; +import type { KeygenEd25519Body } from "@oko-wallet/oko-types/tss"; +import { ErrorCodeMap } from "@oko-wallet/oko-api-error-codes"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import type { SignInResponse } from "@oko-wallet/oko-types/user"; +import type { AuthType } from "@oko-wallet/oko-types/auth"; +import { + ErrorResponseSchema, + OAuthHeaderSchema, +} from "@oko-wallet/oko-api-openapi/common"; +import { + KeygenEd25519RequestSchema, + SignInSuccessResponseSchema, +} from "@oko-wallet/oko-api-openapi/tss"; +import { registry } from "@oko-wallet/oko-api-openapi"; + +import { runKeygenEd25519 } from "@oko-wallet-tss-api/api/keygen_ed25519"; +import { + type OAuthAuthenticatedRequest, + oauthMiddleware, +} from "@oko-wallet-tss-api/middleware/oauth"; +import { tssActivateMiddleware } from "@oko-wallet-tss-api/middleware/tss_activate"; +import type { OAuthLocals } from "@oko-wallet-tss-api/middleware/types"; + +export function setKeygenEd25519Routes(router: Router) { + registry.registerPath({ + method: "post", + path: "/tss/v1/keygen_ed25519", + tags: ["TSS"], + summary: "Run keygen to generate Ed25519 TSS key pair", + description: + "Creates user and wallet entities for Ed25519 by mapping the received key share with the user's email", + security: [{ oauthAuth: [] }], + request: { + headers: OAuthHeaderSchema, + body: { + required: true, + content: { + "application/json": { + schema: KeygenEd25519RequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: "Successfully created user and wallet entities", + content: { + "application/json": { + schema: SignInSuccessResponseSchema, + }, + }, + }, + 401: { + description: "Unauthorized - Invalid or missing OAuth token", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 409: { + description: + "Conflict - Email already exists or public key already in use", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 500: { + description: "Internal server error", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + }, + }); + router.post( + "/keygen_ed25519", + oauthMiddleware, + tssActivateMiddleware, + async ( + req: OAuthAuthenticatedRequest, + res: Response, OAuthLocals>, + ) => { + const state = req.app.locals; + const oauthUser = res.locals.oauth_user; + const auth_type = oauthUser.type as AuthType; + const body = req.body; + + if (!oauthUser?.email) { + res.status(401).json({ + success: false, + code: "UNAUTHORIZED", + msg: "User email not found", + }); + return; + } + + const jwtConfig = { + secret: state.jwt_secret, + expires_in: state.jwt_expires_in, + }; + + const runKeygenRes = await runKeygenEd25519( + state.db, + jwtConfig, + { + auth_type, + email: oauthUser.email.toLowerCase(), + keygen_2: body.keygen_2, + name: oauthUser.name, + }, + state.encryption_secret, + ); + + if (runKeygenRes.success === false) { + res.status(ErrorCodeMap[runKeygenRes.code] ?? 500).json(runKeygenRes); + return; + } + + res.status(200).json({ + success: true, + data: runKeygenRes.data, + }); + return; + }, + ); +} diff --git a/backend/tss_api/src/routes/presign_ed25519.ts b/backend/tss_api/src/routes/presign_ed25519.ts new file mode 100644 index 000000000..b9b57fad0 --- /dev/null +++ b/backend/tss_api/src/routes/presign_ed25519.ts @@ -0,0 +1,119 @@ +import type { Response, Router } from "express"; +import type { + PresignEd25519Body, + PresignEd25519Response, +} from "@oko-wallet/oko-types/tss"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import { ErrorCodeMap } from "@oko-wallet/oko-api-error-codes"; +import { + ErrorResponseSchema, + UserAuthHeaderSchema, +} from "@oko-wallet/oko-api-openapi/common"; +import { registry } from "@oko-wallet/oko-api-openapi"; + +import { runPresignEd25519 } from "@oko-wallet-tss-api/api/presign_ed25519"; +import { + type UserAuthenticatedRequest, + userJwtMiddleware, + sendResponseWithNewToken, +} from "@oko-wallet-tss-api/middleware/keplr_auth"; +import { apiKeyMiddleware } from "@oko-wallet-tss-api/middleware/api_key_auth"; +import { tssActivateMiddleware } from "@oko-wallet-tss-api/middleware/tss_activate"; + +export function setPresignEd25519Routes(router: Router) { + registry.registerPath({ + method: "post", + path: "/tss/v1/presign_ed25519", + tags: ["TSS"], + summary: "Generate Ed25519 presign (nonces and commitments)", + description: + "Pre-generate nonces and commitments for Ed25519 threshold signing. " + + "This can be called before knowing the message to sign.", + security: [{ userAuth: [] }], + request: { + headers: UserAuthHeaderSchema, + body: { + required: false, + content: { + "application/json": { + schema: { + type: "object", + properties: {}, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "Successfully generated presign", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + session_id: { type: "string" }, + commitments_0: { + type: "object", + properties: { + identifier: { type: "array", items: { type: "number" } }, + commitments: { type: "array", items: { type: "number" } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }); + + router.post( + "/presign_ed25519", + [apiKeyMiddleware, userJwtMiddleware, tssActivateMiddleware], + async ( + req: UserAuthenticatedRequest, + res: Response>, + ) => { + const state = req.app.locals as any; + const user = res.locals.user; + const apiKey = res.locals.api_key; + + if (!user.wallet_id_ed25519) { + res.status(400).json({ + success: false, + code: "WALLET_NOT_FOUND", + msg: "Ed25519 wallet not found. Please create one first.", + }); + return; + } + + const result = await runPresignEd25519(state.db, state.encryption_secret, { + email: user.email.toLowerCase(), + wallet_id: user.wallet_id_ed25519, + customer_id: apiKey.customer_id, + }); + + if (result.success === false) { + res.status(ErrorCodeMap[result.code] ?? 500).json(result); + return; + } + + sendResponseWithNewToken(res, result.data); + }, + ); +} diff --git a/backend/tss_api/src/routes/sign_ed25519.ts b/backend/tss_api/src/routes/sign_ed25519.ts new file mode 100644 index 000000000..3dd6d714e --- /dev/null +++ b/backend/tss_api/src/routes/sign_ed25519.ts @@ -0,0 +1,430 @@ +import type { Response, Router } from "express"; +import type { + SignEd25519Round1Body, + SignEd25519Round1Response, + SignEd25519Round2Body, + SignEd25519Round2Response, + SignEd25519AggregateBody, + SignEd25519AggregateResponse, + SignEd25519Body, + SignEd25519Response, +} from "@oko-wallet/oko-types/tss"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import { ErrorCodeMap } from "@oko-wallet/oko-api-error-codes"; +import { + ErrorResponseSchema, + UserAuthHeaderSchema, +} from "@oko-wallet/oko-api-openapi/common"; +import { registry } from "@oko-wallet/oko-api-openapi"; + +import { + runSignEd25519Round1, + runSignEd25519Round2, + runSignEd25519Aggregate, + runSignEd25519, +} from "@oko-wallet-tss-api/api/sign_ed25519"; +import { + type UserAuthenticatedRequest, + userJwtMiddleware, + sendResponseWithNewToken, +} from "@oko-wallet-tss-api/middleware/keplr_auth"; +import { apiKeyMiddleware } from "@oko-wallet-tss-api/middleware/api_key_auth"; +import { tssActivateMiddleware } from "@oko-wallet-tss-api/middleware/tss_activate"; + +export function setSignEd25519Routes(router: Router) { + registry.registerPath({ + method: "post", + path: "/tss/v1/sign_ed25519/round1", + tags: ["TSS"], + summary: "Generate Ed25519 signing commitments (Round 1)", + description: "Server generates nonces and returns commitments for threshold signing", + security: [{ userAuth: [] }], + request: { + headers: UserAuthHeaderSchema, + body: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + msg: { type: "array", items: { type: "number" } }, + }, + required: ["msg"], + }, + }, + }, + }, + }, + responses: { + 200: { + description: "Successfully generated commitments", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + commitments_0: { + type: "object", + properties: { + identifier: { type: "array", items: { type: "number" } }, + commitments: { type: "array", items: { type: "number" } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }); + + router.post( + "/sign_ed25519/round1", + [apiKeyMiddleware, userJwtMiddleware, tssActivateMiddleware], + async ( + req: UserAuthenticatedRequest, + res: Response>, + ) => { + const state = req.app.locals as any; + const user = res.locals.user; + const apiKey = res.locals.api_key; + const body = req.body; + + const result = await runSignEd25519Round1(state.db, state.encryption_secret, { + email: user.email.toLowerCase(), + wallet_id: user.wallet_id, + customer_id: apiKey.customer_id, + msg: body.msg, + }); + + if (result.success === false) { + res.status(ErrorCodeMap[result.code] ?? 500).json(result); + return; + } + + sendResponseWithNewToken(res, result.data); + }, + ); + + registry.registerPath({ + method: "post", + path: "/tss/v1/sign_ed25519/round2", + tags: ["TSS"], + summary: "Generate Ed25519 signature share (Round 2)", + description: "Server generates signature share using collected commitments", + security: [{ userAuth: [] }], + request: { + headers: UserAuthHeaderSchema, + body: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + session_id: { type: "string" }, + commitments_1: { + type: "object", + properties: { + identifier: { type: "array", items: { type: "number" } }, + commitments: { type: "array", items: { type: "number" } }, + }, + }, + }, + required: ["session_id", "commitments_1"], + }, + }, + }, + }, + }, + responses: { + 200: { + description: "Successfully generated signature share", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + signature_share_0: { + type: "object", + properties: { + identifier: { type: "array", items: { type: "number" } }, + signature_share: { type: "array", items: { type: "number" } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }); + + router.post( + "/sign_ed25519/round2", + [userJwtMiddleware, tssActivateMiddleware], + async ( + req: UserAuthenticatedRequest, + res: Response>, + ) => { + const state = req.app.locals as any; + const user = res.locals.user; + const body = req.body; + + const result = await runSignEd25519Round2(state.db, state.encryption_secret, { + email: user.email.toLowerCase(), + wallet_id: user.wallet_id, + session_id: body.session_id, + commitments_1: body.commitments_1, + }); + + if (result.success === false) { + res.status(ErrorCodeMap[result.code] ?? 500).json(result); + return; + } + + sendResponseWithNewToken(res, result.data); + }, + ); + + // New presign-based sign endpoint + registry.registerPath({ + method: "post", + path: "/tss/v1/sign_ed25519", + tags: ["TSS"], + summary: "Sign with Ed25519 using presign session", + description: + "Generate signature share using a pre-generated presign session. " + + "Requires calling presign_ed25519 first to get session_id and server commitments.", + security: [{ userAuth: [] }], + request: { + headers: UserAuthHeaderSchema, + body: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + session_id: { type: "string" }, + msg: { type: "array", items: { type: "number" } }, + commitments_1: { + type: "object", + properties: { + identifier: { type: "array", items: { type: "number" } }, + commitments: { type: "array", items: { type: "number" } }, + }, + }, + }, + required: ["session_id", "msg", "commitments_1"], + }, + }, + }, + }, + }, + responses: { + 200: { + description: "Successfully generated signature share", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + signature_share_0: { + type: "object", + properties: { + identifier: { type: "array", items: { type: "number" } }, + signature_share: { type: "array", items: { type: "number" } }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }); + + router.post( + "/sign_ed25519", + [userJwtMiddleware, tssActivateMiddleware], + async ( + req: UserAuthenticatedRequest, + res: Response>, + ) => { + const state = req.app.locals as any; + const user = res.locals.user; + const body = req.body; + + if (!user.wallet_id_ed25519) { + res.status(400).json({ + success: false, + code: "WALLET_NOT_FOUND", + msg: "Ed25519 wallet not found. Please create one first.", + }); + return; + } + + const result = await runSignEd25519(state.db, state.encryption_secret, { + email: user.email.toLowerCase(), + wallet_id: user.wallet_id_ed25519, + session_id: body.session_id, + msg: body.msg, + commitments_1: body.commitments_1, + }); + + if (result.success === false) { + res.status(ErrorCodeMap[result.code] ?? 500).json(result); + return; + } + + sendResponseWithNewToken(res, result.data); + }, + ); + + registry.registerPath({ + method: "post", + path: "/tss/v1/sign_ed25519/aggregate", + tags: ["TSS"], + summary: "Aggregate Ed25519 signature shares", + description: "Combine all signature shares into a final Ed25519 signature", + security: [{ userAuth: [] }], + request: { + headers: UserAuthHeaderSchema, + body: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + properties: { + msg: { type: "array", items: { type: "number" } }, + all_commitments: { + type: "array", + items: { + type: "object", + properties: { + identifier: { type: "array", items: { type: "number" } }, + commitments: { type: "array", items: { type: "number" } }, + }, + }, + }, + all_signature_shares: { + type: "array", + items: { + type: "object", + properties: { + identifier: { type: "array", items: { type: "number" } }, + signature_share: { type: "array", items: { type: "number" } }, + }, + }, + }, + }, + required: ["msg", "all_commitments", "all_signature_shares"], + }, + }, + }, + }, + }, + responses: { + 200: { + description: "Successfully aggregated signature", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + signature: { type: "array", items: { type: "number" } }, + }, + }, + }, + }, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }); + + router.post( + "/sign_ed25519/aggregate", + [userJwtMiddleware, tssActivateMiddleware], + async ( + req: UserAuthenticatedRequest, + res: Response>, + ) => { + const state = req.app.locals as any; + const user = res.locals.user; + const body = req.body; + + const result = await runSignEd25519Aggregate(state.db, state.encryption_secret, { + email: user.email.toLowerCase(), + wallet_id: user.wallet_id, + msg: body.msg, + all_commitments: body.all_commitments, + all_signature_shares: body.all_signature_shares, + }); + + if (result.success === false) { + res.status(ErrorCodeMap[result.code] ?? 500).json(result); + return; + } + + sendResponseWithNewToken(res, result.data); + }, + ); +} diff --git a/backend/tss_api/src/routes/wallet_ed25519.ts b/backend/tss_api/src/routes/wallet_ed25519.ts new file mode 100644 index 000000000..d5f29446b --- /dev/null +++ b/backend/tss_api/src/routes/wallet_ed25519.ts @@ -0,0 +1,111 @@ +import type { Response, Router } from "express"; +import type { OkoApiResponse } from "@oko-wallet/oko-types/api_response"; +import { ErrorCodeMap } from "@oko-wallet/oko-api-error-codes"; +import { + ErrorResponseSchema, + OAuthHeaderSchema, +} from "@oko-wallet/oko-api-openapi/common"; +import { registry } from "@oko-wallet/oko-api-openapi"; + +import { + getWalletEd25519PublicInfo, + type WalletEd25519PublicInfoResponse, +} from "@oko-wallet-tss-api/api/wallet_ed25519"; +import { + type OAuthAuthenticatedRequest, + oauthMiddleware, +} from "@oko-wallet-tss-api/middleware/oauth"; +import type { OAuthLocals } from "@oko-wallet-tss-api/middleware/types"; +import { tssActivateMiddleware } from "@oko-wallet-tss-api/middleware/tss_activate"; + +export function setWalletEd25519Routes(router: Router) { + registry.registerPath({ + method: "post", + path: "/tss/v1/wallet_ed25519/public_info", + tags: ["TSS"], + summary: "Get Ed25519 wallet public info for recovery", + description: + "Returns the public_key_package and identifier needed to reconstruct " + + "the Ed25519 key_package from KS node shares.", + security: [{ oauthAuth: [] }], + request: { + headers: OAuthHeaderSchema, + }, + responses: { + 200: { + description: "Successfully retrieved Ed25519 public info", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + data: { + type: "object", + properties: { + public_key: { type: "string" }, + public_key_package: { + type: "array", + items: { type: "number" }, + }, + identifier: { type: "array", items: { type: "number" } }, + }, + }, + }, + }, + }, + }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 404: { + description: "Wallet not found", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 500: { + description: "Internal server error", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }); + + router.post( + "/wallet_ed25519/public_info", + oauthMiddleware, + tssActivateMiddleware, + async ( + req: OAuthAuthenticatedRequest>, + res: Response, OAuthLocals>, + ) => { + const state = req.app.locals; + const oauthUser = res.locals.oauth_user; + + if (!oauthUser?.email) { + res.status(401).json({ + success: false, + code: "UNAUTHORIZED", + msg: "User email not found", + }); + return; + } + + const result = await getWalletEd25519PublicInfo( + state.db, + state.encryption_secret, + { + email: oauthUser.email.toLowerCase(), + auth_type: oauthUser.type, + }, + ); + + if (result.success === false) { + res.status(ErrorCodeMap[result.code] ?? 500).json(result); + return; + } + + res.status(200).json(result); + }, + ); +} diff --git a/common/oko_types/package.json b/common/oko_types/package.json index 491186bd6..a28977e6b 100644 --- a/common/oko_types/package.json +++ b/common/oko_types/package.json @@ -33,6 +33,7 @@ "@oko-wallet/bytes": "^0.0.3-alpha.64", "@oko-wallet/stdlib-js": "0.0.2-rc.41", "@oko-wallet/tecdsa-interface": "0.0.2-alpha.22", + "@oko-wallet/teddsa-interface": "workspace:*", "del-cli": "^6.0.0", "tsc-alias": "^1.8.16", "viem": "^2.41.2" diff --git a/common/oko_types/src/api_response/index.ts b/common/oko_types/src/api_response/index.ts index d734c27f0..4b4a2161c 100644 --- a/common/oko_types/src/api_response/index.ts +++ b/common/oko_types/src/api_response/index.ts @@ -51,5 +51,6 @@ export type ErrorCode = | "TSS_ACTIVATION_SETTING_NOT_FOUND" | "IMAGE_UPLOAD_FAILED" | "INVALID_PUBLIC_KEY" + | "INVALID_WALLET_TYPE" | "REFERRAL_NOT_FOUND" | "UNKNOWN_ERROR"; diff --git a/common/oko_types/src/crypto/index.ts b/common/oko_types/src/crypto/index.ts index 024e22cb0..6848caee0 100644 --- a/common/oko_types/src/crypto/index.ts +++ b/common/oko_types/src/crypto/index.ts @@ -1,3 +1,3 @@ export type ShardType = "sss_gf256"; -export type CurveType = "secp256k1"; +export type CurveType = "secp256k1" | "ed25519"; diff --git a/common/oko_types/src/tss/auth.ts b/common/oko_types/src/tss/auth.ts index e2c83f765..cc2913138 100644 --- a/common/oko_types/src/tss/auth.ts +++ b/common/oko_types/src/tss/auth.ts @@ -1,11 +1,13 @@ export interface UserTokenPayload { email: string; wallet_id: string; + wallet_id_ed25519?: string; type: "user"; } export interface GenerateUserTokenArgs { wallet_id: string; + wallet_id_ed25519?: string; email: string; jwt_config: { secret: string; diff --git a/common/oko_types/src/tss/index.ts b/common/oko_types/src/tss/index.ts index 072d058fd..1830f7798 100644 --- a/common/oko_types/src/tss/index.ts +++ b/common/oko_types/src/tss/index.ts @@ -1,7 +1,9 @@ export * from "./keygen"; +export * from "./keygen_ed25519"; export * from "./triples"; export * from "./presign"; export * from "./sign"; +export * from "./sign_ed25519"; export * from "./tss_session"; export * from "./tss_stage"; export * from "./auth"; diff --git a/common/oko_types/src/tss/keygen_ed25519.ts b/common/oko_types/src/tss/keygen_ed25519.ts new file mode 100644 index 000000000..31d969507 --- /dev/null +++ b/common/oko_types/src/tss/keygen_ed25519.ts @@ -0,0 +1,20 @@ +import type { TeddsaKeygenOutput } from "@oko-wallet/teddsa-interface"; + +import type { AuthType, OAuthRequest } from "../auth"; + +export interface TeddsaKeygenOutputWithPublicKey extends TeddsaKeygenOutput { + public_key: number[]; +} + +export interface KeygenEd25519Request { + auth_type: AuthType; + email: string; + keygen_2: TeddsaKeygenOutputWithPublicKey; + name?: string; +} + +export type KeygenEd25519Body = { + keygen_2: TeddsaKeygenOutputWithPublicKey; +}; + +export type KeygenEd25519RequestBody = OAuthRequest; diff --git a/common/oko_types/src/tss/sign_ed25519.ts b/common/oko_types/src/tss/sign_ed25519.ts new file mode 100644 index 000000000..f1ab56146 --- /dev/null +++ b/common/oko_types/src/tss/sign_ed25519.ts @@ -0,0 +1,100 @@ +import type { + TeddsaSignRound1Output, + TeddsaSignRound2Output, + TeddsaCommitmentEntry, + TeddsaSignatureShareEntry, +} from "@oko-wallet/teddsa-interface"; + +export interface SignEd25519Round1Request { + email: string; + wallet_id: string; + customer_id: string; + msg: number[]; +} + +export interface SignEd25519Round1Response { + session_id: string; + commitments_0: TeddsaCommitmentEntry; +} + +export type SignEd25519Round1Body = { + msg: number[]; +}; + +export interface SignEd25519Round2Request { + email: string; + wallet_id: string; + session_id: string; + commitments_1: TeddsaCommitmentEntry; +} + +export interface SignEd25519Round2Response { + signature_share_0: TeddsaSignatureShareEntry; +} + +export type SignEd25519Round2Body = { + session_id: string; + commitments_1: TeddsaCommitmentEntry; +}; + +export interface SignEd25519AggregateRequest { + email: string; + wallet_id: string; + msg: number[]; + all_commitments: TeddsaCommitmentEntry[]; + all_signature_shares: TeddsaSignatureShareEntry[]; +} + +export interface SignEd25519AggregateResponse { + signature: number[]; +} + +export type SignEd25519AggregateBody = { + msg: number[]; + all_commitments: TeddsaCommitmentEntry[]; + all_signature_shares: TeddsaSignatureShareEntry[]; +}; + +export interface SignEd25519ServerState { + nonces: number[]; + identifier: number[]; +} + +// ============================================ +// Presign Ed25519 Types (message-independent) +// ============================================ + +export interface PresignEd25519Request { + email: string; + wallet_id: string; + customer_id: string; +} + +export interface PresignEd25519Response { + session_id: string; + commitments_0: TeddsaCommitmentEntry; +} + +export type PresignEd25519Body = Record; + +// ============================================ +// Sign Ed25519 Types (using presign session) +// ============================================ + +export interface SignEd25519Request { + email: string; + wallet_id: string; + session_id: string; + msg: number[]; + commitments_1: TeddsaCommitmentEntry; +} + +export interface SignEd25519Response { + signature_share_0: TeddsaSignatureShareEntry; +} + +export type SignEd25519Body = { + session_id: string; + msg: number[]; + commitments_1: TeddsaCommitmentEntry; +}; diff --git a/common/oko_types/src/tss/tss_stage.ts b/common/oko_types/src/tss/tss_stage.ts index 4b98d8877..d97724b28 100644 --- a/common/oko_types/src/tss/tss_stage.ts +++ b/common/oko_types/src/tss/tss_stage.ts @@ -17,6 +17,8 @@ export enum TssStageType { TRIPLES = "TRIPLES", PRESIGN = "PRESIGN", SIGN = "SIGN", + SIGN_ED25519 = "SIGN_ED25519", + PRESIGN_ED25519 = "PRESIGN_ED25519", } interface TssStageBase { @@ -58,6 +60,19 @@ export enum SignStageStatus { FAILED = "FAILED", } +export enum SignEd25519StageStatus { + ROUND_1 = "ROUND_1_COMPLETED", + ROUND_2 = "ROUND_2_COMPLETED", + COMPLETED = "COMPLETED", + FAILED = "FAILED", +} + +export enum PresignEd25519StageStatus { + COMPLETED = "COMPLETED", + USED = "USED", + FAILED = "FAILED", +} + export interface TriplesStageData { triple_state: TriplesState | null; triple_messages: RcvdTriplesMessages | null; @@ -79,6 +94,20 @@ export interface SignStageData { sign_output: SignOutput | null; } +export interface SignEd25519StageData { + nonces: number[] | null; + identifier: number[] | null; + commitments: number[] | null; + signature_share: number[] | null; + signature: number[] | null; +} + +export interface PresignEd25519StageData { + nonces: number[]; + identifier: number[]; + commitments: number[]; +} + export type TriplesStage = TssStageBase & { stage_type: TssStageType.TRIPLES; stage_status: TriplesStageStatus; @@ -97,12 +126,26 @@ export type SignStage = TssStageBase & { stage_data: SignStageData; }; +export type SignEd25519Stage = TssStageBase & { + stage_type: TssStageType.SIGN_ED25519; + stage_status: SignEd25519StageStatus; + stage_data: SignEd25519StageData; +}; + +export type PresignEd25519Stage = TssStageBase & { + stage_type: TssStageType.PRESIGN_ED25519; + stage_status: PresignEd25519StageStatus; + stage_data: PresignEd25519StageData; +}; + export type TssStageStatus = | TriplesStageStatus | PresignStageStatus - | SignStageStatus; + | SignStageStatus + | SignEd25519StageStatus + | PresignEd25519StageStatus; -export type TssStage = TriplesStage | PresignStage | SignStage; +export type TssStage = TriplesStage | PresignStage | SignStage | SignEd25519Stage | PresignEd25519Stage; export type CreateTssStageRequest = Pick< TssStage, diff --git a/crypto/teddsa/api_lib/package.json b/crypto/teddsa/api_lib/package.json new file mode 100644 index 000000000..86c15d3a5 --- /dev/null +++ b/crypto/teddsa/api_lib/package.json @@ -0,0 +1,13 @@ +{ + "name": "@oko-wallet/teddsa-api-lib", + "private": true, + "main": "./src/index.ts", + "type": "module", + "dependencies": { + "@oko-wallet/oko-types": "^0.0.1-alpha.4" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "^5.8.3" + } +} diff --git a/crypto/teddsa/api_lib/src/index.ts b/crypto/teddsa/api_lib/src/index.ts new file mode 100644 index 000000000..51ef79981 --- /dev/null +++ b/crypto/teddsa/api_lib/src/index.ts @@ -0,0 +1,171 @@ +import type { + KeygenEd25519Body, + SignEd25519Round1Body, + SignEd25519Round1Response, + SignEd25519Round2Body, + SignEd25519Round2Response, + SignEd25519AggregateBody, + SignEd25519AggregateResponse, +} from "@oko-wallet/oko-types/tss"; +import type { SignInResponse } from "@oko-wallet/oko-types/user"; +import type { + ErrorCode, + OkoApiErrorResponse, + OkoApiResponse, +} from "@oko-wallet/oko-types/api_response"; + +interface MiddlewareErrorResponse { + error: string; +} + +function isMiddlewareError(response: any): response is MiddlewareErrorResponse { + return ( + typeof response === "object" && + response !== null && + "error" in response && + typeof response.error === "string" && + !("success" in response) + ); +} + +function mapStatusToErrorCode(status: number): ErrorCode { + switch (status) { + case 401: + return "UNAUTHORIZED"; + case 403: + return "AUTHENTICATION_FAILED"; + case 404: + return "USER_NOT_FOUND"; + case 500: + return "UNKNOWN_ERROR"; + default: + return "UNKNOWN_ERROR"; + } +} + +function normalizeErrorResponse( + status: number, + responseBody: any, +): OkoApiErrorResponse { + if (isMiddlewareError(responseBody)) { + return { + success: false, + code: mapStatusToErrorCode(status), + msg: responseBody.error, + }; + } + + if ( + responseBody && + typeof responseBody === "object" && + "success" in responseBody + ) { + return responseBody; + } + + return { + success: false, + code: mapStatusToErrorCode(status), + msg: typeof responseBody === "string" ? responseBody : "Unknown error", + }; +} + +async function makePostRequest( + endpoint: string, + path: string, + payload: T, + authToken?: string, +): Promise { + const url = `${endpoint}/${path}`; + + const headers: Record = { + "Content-Type": "application/json", + }; + + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + + const ret = await fetch(url, { + headers, + method: "POST", + body: JSON.stringify(payload), + }); + + if (ret.status !== 200) { + try { + const error = await ret.json(); + const errorResponse: OkoApiErrorResponse = normalizeErrorResponse( + ret.status, + error, + ); + return Promise.resolve(errorResponse as R); + } catch (err: any) { + console.error("err: %s", err); + return { + success: false, + code: "UNKNOWN_ERROR", + msg: err.toString(), + } as R; + } + } + + return ret.json(); +} + +export async function reqKeygenEd25519( + endpoint: string, + payload: KeygenEd25519Body, + authToken: string, +) { + const resp: OkoApiResponse = await makePostRequest( + endpoint, + "keygen_ed25519", + payload, + authToken, + ); + return resp; +} + +export async function reqSignEd25519Round1( + endpoint: string, + payload: SignEd25519Round1Body, + authToken: string, +) { + const resp: OkoApiResponse = await makePostRequest( + endpoint, + "sign_ed25519/round1", + payload, + authToken, + ); + return resp; +} + +export async function reqSignEd25519Round2( + endpoint: string, + payload: SignEd25519Round2Body, + authToken: string, +) { + const resp: OkoApiResponse = await makePostRequest( + endpoint, + "sign_ed25519/round2", + payload, + authToken, + ); + return resp; +} + +export async function reqSignEd25519Aggregate( + endpoint: string, + payload: SignEd25519AggregateBody, + authToken: string, +) { + const resp: OkoApiResponse = + await makePostRequest( + endpoint, + "sign_ed25519/aggregate", + payload, + authToken, + ); + return resp; +} diff --git a/crypto/teddsa/api_lib/tsconfig.json b/crypto/teddsa/api_lib/tsconfig.json new file mode 100644 index 000000000..c8c92cbd6 --- /dev/null +++ b/crypto/teddsa/api_lib/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/crypto/teddsa/frost_ed25519_keplr/benches/bench.rs b/crypto/teddsa/frost_ed25519_keplr/benches/bench.rs index 4317e05b1..dabfae4a7 100644 --- a/crypto/teddsa/frost_ed25519_keplr/benches/bench.rs +++ b/crypto/teddsa/frost_ed25519_keplr/benches/bench.rs @@ -1,6 +1,6 @@ use criterion::{criterion_group, criterion_main, Criterion}; -use frost_ed25519::*; +use frost_ed25519_keplr::*; fn bench_ed25519_batch_verify(c: &mut Criterion) { let mut rng = rand::rngs::OsRng; diff --git a/crypto/teddsa/frost_ed25519_keplr/src/sss/combine.rs b/crypto/teddsa/frost_ed25519_keplr/src/sss/combine.rs index 995d1a8bc..b4f0b7737 100644 --- a/crypto/teddsa/frost_ed25519_keplr/src/sss/combine.rs +++ b/crypto/teddsa/frost_ed25519_keplr/src/sss/combine.rs @@ -1,21 +1,22 @@ -use alloc::string::{String, ToString}; -use alloc::vec::Vec; +use alloc::string::String; -use crate::point::Point256; -use crate::sss::interpolate_ed25519; +use crate::keys::{reconstruct, KeyPackage}; -/// Combines Ed25519 shares to recover the original secret. -pub fn sss_combine_ed25519(split_points: Vec, t: u32) -> Result<[u8; 32], String> { - if split_points.len() < t as usize { - return Err("Not enough keyshare points to combine".to_string()); - } +/// Combines Ed25519 key packages to recover the original secret using FROST's reconstruct. +/// +/// This uses Lagrange interpolation internally to recover the original signing key +/// from threshold-many KeyPackages. +/// +/// NOTE: The caller must provide at least `min_signers` key packages. +/// If fewer are provided, a different (incorrect) key will be returned. +pub fn sss_combine_ed25519(key_packages: &[KeyPackage]) -> Result<[u8; 32], String> { + let signing_key = + reconstruct(key_packages).map_err(|e| alloc::format!("Failed to reconstruct: {}", e))?; - let truncated_split_points = split_points.iter().take(t as usize).collect::>(); + let serialized = signing_key.serialize(); + let result: [u8; 32] = serialized + .try_into() + .map_err(|_| alloc::format!("Invalid signing key length"))?; - let combined_secret = interpolate_ed25519(truncated_split_points); - if combined_secret.is_err() { - return Err(combined_secret.err().unwrap()); - } - - Ok(combined_secret.unwrap()) + Ok(result) } diff --git a/crypto/teddsa/frost_ed25519_keplr/src/sss/reshare.rs b/crypto/teddsa/frost_ed25519_keplr/src/sss/reshare.rs index ce08f9b4b..76f5c0241 100644 --- a/crypto/teddsa/frost_ed25519_keplr/src/sss/reshare.rs +++ b/crypto/teddsa/frost_ed25519_keplr/src/sss/reshare.rs @@ -1,206 +1,53 @@ -use alloc::collections::BTreeSet; -use alloc::format; use alloc::string::{String, ToString}; use alloc::vec::Vec; -use frost_core::{Scalar, SigningKey}; use rand_core::{CryptoRng, RngCore}; -use crate::keys::{split, IdentifierList}; -use crate::point::Point256; -use crate::sss::compute_lagrange_coefficient; -use crate::{Ed25519Sha512, Identifier}; +use crate::keys::{KeyPackage, PublicKeyPackage}; +use crate::sss::{sss_combine_ed25519, sss_split_ed25519, SplitOutput}; -/// Result of a reshare operation. -#[derive(Debug, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct ReshareResult { - /// Threshold value (minimum shares required to reconstruct). - pub t: u32, - /// The reshared points for each node. - pub reshared_points: Vec, - /// The recovered secret. +/// Result of a reshare operation using VSS-based KeyPackages. +pub struct ReshareOutput { + /// Key packages for each new participant + pub key_packages: Vec, + /// The public key package shared among all participants + pub public_key_package: PublicKeyPackage, + /// The recovered secret (for verification purposes) pub secret: [u8; 32], } -/// Reshares existing keyshares to a new set of nodes with a fresh polynomial. +/// Reshares existing key packages to a new set of nodes with a fresh polynomial. /// -/// This function recovers the secret from existing shares and creates new shares -/// for a potentially different set of nodes using a new random polynomial. +/// This function recovers the secret from existing KeyPackages and creates new +/// KeyPackages for a potentially different set of nodes using a new random polynomial. +/// +/// # Arguments +/// * `key_packages` - Existing key packages (at least min_signers required) +/// * `new_identifiers` - Identifiers for the new set of nodes +/// * `new_min_signers` - New threshold value +/// * `rng` - Random number generator pub fn reshare( - split_points: Vec, - new_ks_node_hashes: Vec<[u8; 32]>, - t: u32, + key_packages: &[KeyPackage], + new_identifiers: Vec<[u8; 32]>, + new_min_signers: u16, rng: &mut R, -) -> Result { - if split_points.len() < t as usize { - return Err("Split points must be greater than t".to_string()); - } - - if new_ks_node_hashes.len() < t as usize { - return Err("New KS node hashes must be greater than t".to_string()); +) -> Result { + if key_packages.is_empty() { + return Err("No key packages provided".to_string()); } - // Take first t points for interpolation - let truncated_points = split_points.iter().take(t as usize).collect::>(); - - // Build identifier set from x coordinates - let identifiers = truncated_points - .iter() - .map(|p| { - Identifier::deserialize(p.x.as_slice()) - .map_err(|e| format!("Failed to deserialize identifier: {:?}", e)) - }) - .collect::, String>>()?; - - // Compute Lagrange coefficients and interpolate to recover secret - let coeffs = identifiers - .iter() - .map(|id| { - compute_lagrange_coefficient::(&identifiers, None, *id) - .map_err(|e| format!("Failed to compute lagrange coefficient: {:?}", e)) - }) - .collect::, String>>()?; - - let mut secret_scalar = Scalar::::ZERO; - for (i, coeff) in coeffs.iter().enumerate() { - let y_scalar = SigningKey::::deserialize(truncated_points[i].y.as_slice()) - .map_err(|e| format!("Failed to deserialize signing key: {:?}", e))? - .to_scalar(); - secret_scalar = secret_scalar + *coeff * y_scalar; + if new_identifiers.len() < new_min_signers as usize { + return Err("New identifiers must be at least new_min_signers".to_string()); } - let secret = secret_scalar.to_bytes(); - - // Create new shares using fresh polynomial - let signing_key = SigningKey::::deserialize(secret.as_slice()) - .map_err(|e| format!("Failed to deserialize signing key: {:?}", e))?; - - let max_signers = new_ks_node_hashes.len() as u16; - let min_signers = t as u16; - - let new_identifiers = new_ks_node_hashes - .iter() - .map(|&x| { - Identifier::deserialize(x.as_slice()) - .map_err(|e| format!("Failed to deserialize identifier: {:?}", e)) - }) - .collect::, String>>()?; - let identifier_list = IdentifierList::Custom(&new_identifiers); - - let share_map_tup = split(&signing_key, max_signers, min_signers, identifier_list, rng) - .map_err(|e| format!("Failed to split: {:?}", e))?; - let share_vec = share_map_tup.0.into_iter().collect::>(); - - let reshared_points: Vec = share_vec - .into_iter() - .map(|(identifier, share)| Point256 { - x: identifier.to_scalar().to_bytes(), - y: share.signing_share().to_scalar().to_bytes(), - }) - .collect(); - - Ok(ReshareResult { - t, - reshared_points, - secret, - }) -} - -/// Expands existing shares to include additional nodes without changing the polynomial. -/// -/// This function uses Lagrange interpolation to compute share values for new nodes -/// based on the existing shares, preserving the original polynomial. -pub fn expand_shares( - split_points: Vec, - additional_ks_node_hashes: Vec<[u8; 32]>, - t: u32, -) -> Result { - if split_points.len() < t as usize { - return Err("Split points must be greater than t".to_string()); - } - - // Check that new hashes are not already in split_points - for split_point in split_points.iter() { - for new_hash in additional_ks_node_hashes.iter() { - if split_point.x == *new_hash { - return Err("New hash is already included in split points".to_string()); - } - } - } - - // Take first t points for interpolation - let truncated_points = split_points.iter().take(t as usize).collect::>(); - - // Build identifier set from x coordinates - let identifiers = truncated_points - .iter() - .map(|p| { - Identifier::deserialize(p.x.as_slice()) - .map_err(|e| format!("Failed to deserialize identifier: {:?}", e)) - }) - .collect::, String>>()?; - - // Recover secret for result - let coeffs_at_zero = identifiers - .iter() - .map(|id| { - compute_lagrange_coefficient::(&identifiers, None, *id) - .map_err(|e| format!("Failed to compute lagrange coefficient: {:?}", e)) - }) - .collect::, String>>()?; - - let mut secret_scalar = Scalar::::ZERO; - for (i, coeff) in coeffs_at_zero.iter().enumerate() { - let y_scalar = SigningKey::::deserialize(truncated_points[i].y.as_slice()) - .map_err(|e| format!("Failed to deserialize signing key: {:?}", e))? - .to_scalar(); - secret_scalar = secret_scalar + *coeff * y_scalar; - } - let secret = secret_scalar.to_bytes(); - - // Compute new points using Lagrange interpolation at new x values - let new_points = additional_ks_node_hashes - .iter() - .map(|&new_hash| { - let x_identifier = Identifier::deserialize(new_hash.as_slice()) - .map_err(|e| format!("Failed to deserialize identifier: {:?}", e))?; - - // Compute Lagrange coefficients at the new x value - let coeffs_at_x = identifiers - .iter() - .map(|id| { - compute_lagrange_coefficient::( - &identifiers, - Some(x_identifier), - *id, - ) - .map_err(|e| format!("Failed to compute lagrange coefficient: {:?}", e)) - }) - .collect::, String>>()?; - - // Interpolate y value - let mut y_scalar = Scalar::::ZERO; - for (i, coeff) in coeffs_at_x.iter().enumerate() { - let point_y_scalar = - SigningKey::::deserialize(truncated_points[i].y.as_slice()) - .map_err(|e| format!("Failed to deserialize signing key: {:?}", e))? - .to_scalar(); - y_scalar = y_scalar + *coeff * point_y_scalar; - } - - Ok(Point256 { - x: x_identifier.to_scalar().to_bytes(), - y: y_scalar.to_bytes(), - }) - }) - .collect::, String>>()?; + let secret = sss_combine_ed25519(key_packages)?; - let reshared_points = [split_points.clone(), new_points].concat(); + let split_output: SplitOutput = + sss_split_ed25519(secret, new_identifiers, new_min_signers, rng)?; - Ok(ReshareResult { - t, - reshared_points, + Ok(ReshareOutput { + key_packages: split_output.key_packages, + public_key_package: split_output.public_key_package, secret, }) } diff --git a/crypto/teddsa/frost_ed25519_keplr/src/sss/split.rs b/crypto/teddsa/frost_ed25519_keplr/src/sss/split.rs index 293adce8d..f13b168c6 100644 --- a/crypto/teddsa/frost_ed25519_keplr/src/sss/split.rs +++ b/crypto/teddsa/frost_ed25519_keplr/src/sss/split.rs @@ -4,42 +4,61 @@ use alloc::vec::Vec; use frost_core::SigningKey; use rand_core::{CryptoRng, RngCore}; -use crate::keys::{split, IdentifierList}; -use crate::point::Point256; +use crate::keys::{split, IdentifierList, KeyPackage, PublicKeyPackage}; use crate::{Ed25519Sha512, Identifier}; -/// Splits an Ed25519 secret into shares using Shamir's Secret Sharing. +/// Output of the split operation using VSS (Verifiable Secret Sharing). +pub struct SplitOutput { + /// Key packages for each participant + pub key_packages: Vec, + /// The public key package shared among all participants + pub public_key_package: PublicKeyPackage, +} + +/// Splits an Ed25519 secret into shares using FROST's VSS (Verifiable Secret Sharing). +/// +/// This produces KeyPackage objects that can be used directly for FROST signing, +/// along with a PublicKeyPackage that contains the group public key. pub fn sss_split_ed25519( secret: [u8; 32], - point_xs: Vec<[u8; 32]>, - t: u32, + identifiers: Vec<[u8; 32]>, + min_signers: u16, rng: &mut R, -) -> Result, String> { +) -> Result { let signing_key = SigningKey::::deserialize(secret.as_slice()) - .expect("Failed to deserialize signing key"); + .map_err(|e| alloc::format!("Failed to deserialize signing key: {}", e))?; - let max_signers = point_xs.len() as u16; - let min_signers = t as u16; + let max_signers = identifiers.len() as u16; - let identifiers = point_xs + let identifier_list: Vec = identifiers .iter() - .map(|&x| Identifier::deserialize(x.as_slice()).expect("Failed to deserialize identifier")) - .collect::>(); - let identifier_list = IdentifierList::Custom(&identifiers); - - let share_map_tup = split(&signing_key, max_signers, min_signers, identifier_list, rng) - .expect("Failed to split"); - let share_vec = share_map_tup.0.into_iter().collect::>(); - - let share_points: Vec = share_vec + .map(|x| { + Identifier::deserialize(x.as_slice()) + .map_err(|e| alloc::format!("Failed to deserialize identifier: {}", e)) + }) + .collect::, _>>()?; + + let (shares, public_key_package) = split( + &signing_key, + max_signers, + min_signers, + IdentifierList::Custom(&identifier_list), + rng, + ) + .map_err(|e| alloc::format!("Failed to split: {}", e))?; + + let key_packages: Vec = shares .into_iter() - .map(|(identifier, share)| Point256 { - x: identifier.to_scalar().to_bytes(), - y: share.signing_share().to_scalar().to_bytes(), + .map(|(_, secret_share)| { + KeyPackage::try_from(secret_share) + .map_err(|e| alloc::format!("Failed to convert to KeyPackage: {}", e)) }) - .collect(); + .collect::, _>>()?; - Ok(share_points) + Ok(SplitOutput { + key_packages, + public_key_package, + }) } #[cfg(test)] @@ -54,48 +73,36 @@ mod tests { #[test] fn test_sss_split_ed25519() { let mut secret = [0u8; 32]; - secret[31] = 1; - let mut point_xs = vec![[0u8; 32]; 3]; - point_xs[0][31] = 1; - point_xs[1][31] = 2; - point_xs[2][31] = 3; - let t = 2; - - let max_signers = point_xs.len() as u16; - let min_signers = t as u16; - - let signing_key = SigningKey::::deserialize(secret.as_slice()) - .expect("Failed to deserialize signing key"); - - let identifiers = point_xs - .iter() - .map(|&x| { - Identifier::deserialize(x.as_slice()).expect("Failed to deserialize identifier") - }) - .collect::>(); - let identifier_list = IdentifierList::Custom(&identifiers); + secret[0] = 1; // little-endian: 1 + let mut identifiers = vec![[0u8; 32]; 3]; + identifiers[0][0] = 1; + identifiers[1][0] = 2; + identifiers[2][0] = 3; + let min_signers = 2; let mut rng = OsRng; - let out = split( - &signing_key, - max_signers, - min_signers, - identifier_list, - &mut rng, - ) - .expect("Failed to split"); - - let i_0 = identifiers.get(0).unwrap(); - let out_0 = out.0.get(identifiers.get(0).unwrap()).unwrap(); - let out_0_signing_share = out_0.signing_share(); - eprintln!("out_0_signing_share: {:?}", out_0_signing_share.to_scalar()); - eprintln!("i_0: {:?}", i_0.to_scalar().to_bytes()); - - let i_1 = identifiers.get(1).unwrap(); - let out_1 = out.0.get(identifiers.get(1).unwrap()).unwrap(); - let out_1_signing_share = out_1.signing_share(); - eprintln!("out_1_signing_share: {:?}", out_1_signing_share.to_scalar()); - eprintln!("i_1: {:?}", i_1.to_scalar().to_bytes()); + let output = + sss_split_ed25519(secret, identifiers, min_signers, &mut rng).expect("Failed to split"); + + assert_eq!(output.key_packages.len(), 3); + + for (i, key_package) in output.key_packages.iter().enumerate() { + eprintln!( + "key_package[{}] identifier: {:?}", + i, + key_package.identifier() + ); + eprintln!( + "key_package[{}] signing_share: {:?}", + i, + key_package.signing_share() + ); + } + + eprintln!( + "public_key_package verifying_key: {:?}", + output.public_key_package.verifying_key() + ); } #[test] diff --git a/crypto/teddsa/frost_ed25519_keplr/src/sss/tests.rs b/crypto/teddsa/frost_ed25519_keplr/src/sss/tests.rs index 1fdfe5d37..217d3db5e 100644 --- a/crypto/teddsa/frost_ed25519_keplr/src/sss/tests.rs +++ b/crypto/teddsa/frost_ed25519_keplr/src/sss/tests.rs @@ -1,208 +1,178 @@ use alloc::vec; +use alloc::vec::Vec; use rand_core::OsRng; -use crate::sss::{expand_shares, reshare, sss_combine_ed25519, sss_split_ed25519}; +use crate::sss::{reshare, sss_combine_ed25519, sss_split_ed25519}; +/// Test split and combine using VSS-based KeyPackages. #[test] -fn test_sss_combine_ed25519() { - let mut secret = [0; 32]; - secret[0] = 1; +fn test_sss_split_and_combine_ed25519() { + let mut secret = [0u8; 32]; + secret[0] = 1; // little-endian: 1 - let mut point_1 = [0; 32]; - point_1[0] = 1; - let mut point_2 = [0; 32]; - point_2[0] = 2; - let mut point_3 = [0; 32]; - point_3[0] = 3; + let mut id_1 = [0u8; 32]; + id_1[0] = 1; + let mut id_2 = [0u8; 32]; + id_2[0] = 2; + let mut id_3 = [0u8; 32]; + id_3[0] = 3; let mut rng = OsRng; - let point_xs = vec![point_1, point_2, point_3]; - let split_points = sss_split_ed25519(secret, point_xs, 2, &mut rng).unwrap(); - let combined_secret = sss_combine_ed25519(split_points, 2).unwrap(); + let identifiers = vec![id_1, id_2, id_3]; + let split_output = sss_split_ed25519(secret, identifiers, 2, &mut rng).unwrap(); + + assert_eq!(split_output.key_packages.len(), 3); + + // Combine using first 2 key packages (threshold = 2) + let key_packages_for_combine: Vec<_> = + split_output.key_packages.iter().take(2).cloned().collect(); + let combined_secret = sss_combine_ed25519(&key_packages_for_combine).unwrap(); assert_eq!(combined_secret, secret); } +/// Test that combining with different threshold-many subsets gives the same result. #[test] -fn test_reshare() { - let mut secret = [0; 32]; - secret[31] = 1; +fn test_sss_combine_different_subsets() { + let mut secret = [0u8; 32]; + secret[0] = 42; - let mut point_1 = [0; 32]; - point_1[31] = 1; - let mut point_2 = [0; 32]; - point_2[31] = 2; - let mut point_3 = [0; 32]; - point_3[31] = 3; + let mut id_1 = [0u8; 32]; + id_1[0] = 1; + let mut id_2 = [0u8; 32]; + id_2[0] = 2; + let mut id_3 = [0u8; 32]; + id_3[0] = 3; let mut rng = OsRng; - let point_xs = vec![point_1, point_2, point_3]; - let split_points = sss_split_ed25519(secret, point_xs, 2, &mut rng).unwrap(); - - // Reshare to new nodes - let mut new_point_1 = [0; 32]; - new_point_1[31] = 4; - let mut new_point_2 = [0; 32]; - new_point_2[31] = 5; - let mut new_point_3 = [0; 32]; - new_point_3[31] = 6; + let identifiers = vec![id_1, id_2, id_3]; + let split_output = sss_split_ed25519(secret, identifiers, 2, &mut rng).unwrap(); - let new_ks_node_hashes = vec![new_point_1, new_point_2, new_point_3]; - let reshare_result = reshare(split_points, new_ks_node_hashes, 2, &mut rng).unwrap(); + // Combine using packages 0 and 1 + let subset_01: Vec<_> = vec![ + split_output.key_packages[0].clone(), + split_output.key_packages[1].clone(), + ]; + let combined_01 = sss_combine_ed25519(&subset_01).unwrap(); + assert_eq!(combined_01, secret); - // Verify reshared secret matches original - assert_eq!(reshare_result.secret, secret); - assert_eq!(reshare_result.t, 2); - assert_eq!(reshare_result.reshared_points.len(), 3); + // Combine using packages 0 and 2 + let subset_02: Vec<_> = vec![ + split_output.key_packages[0].clone(), + split_output.key_packages[2].clone(), + ]; + let combined_02 = sss_combine_ed25519(&subset_02).unwrap(); + assert_eq!(combined_02, secret); - // Verify we can combine the reshared points to recover secret - let combined_secret = sss_combine_ed25519(reshare_result.reshared_points, 2).unwrap(); - assert_eq!(combined_secret, secret); + // Combine using packages 1 and 2 + let subset_12: Vec<_> = vec![ + split_output.key_packages[1].clone(), + split_output.key_packages[2].clone(), + ]; + let combined_12 = sss_combine_ed25519(&subset_12).unwrap(); + assert_eq!(combined_12, secret); } +/// Test 2-of-2 threshold scheme (used by the wallet). #[test] -fn test_expand_shares() { - let mut secret = [0; 32]; - secret[0] = 1; +fn test_sss_2_of_2_threshold() { + let mut secret = [0u8; 32]; + secret[0] = 123; - let mut point_1 = [0; 32]; - point_1[0] = 1; - let mut point_2 = [0; 32]; - point_2[0] = 2; - let mut point_3 = [0; 32]; - point_3[0] = 3; + let mut id_1 = [0u8; 32]; + id_1[0] = 1; + let mut id_2 = [0u8; 32]; + id_2[0] = 2; let mut rng = OsRng; - let point_xs = vec![point_1, point_2, point_3]; - let split_points = sss_split_ed25519(secret, point_xs, 2, &mut rng).unwrap(); - - // Expand with additional nodes - let mut new_point_1 = [0; 32]; - new_point_1[0] = 4; - let mut new_point_2 = [0; 32]; - new_point_2[0] = 5; - - let additional_hashes = vec![new_point_1, new_point_2]; - let expand_result = expand_shares(split_points.clone(), additional_hashes, 2).unwrap(); - - // Verify secret is recovered correctly - assert_eq!(expand_result.secret, secret); - assert_eq!(expand_result.t, 2); - // Original 3 points + 2 new points = 5 - assert_eq!(expand_result.reshared_points.len(), 5); - - // Verify we can combine any 2 of the expanded points to recover secret - // Use new points only - let new_points_only = expand_result.reshared_points[3..].to_vec(); - let combined_from_new = sss_combine_ed25519(new_points_only, 2).unwrap(); - assert_eq!(combined_from_new, secret); - - // Use mix of old and new - let mixed_points = vec![ - expand_result.reshared_points[0].clone(), - expand_result.reshared_points[4].clone(), - ]; - let combined_from_mixed = sss_combine_ed25519(mixed_points, 2).unwrap(); - assert_eq!(combined_from_mixed, secret); + let identifiers = vec![id_1, id_2]; + let split_output = sss_split_ed25519(secret, identifiers, 2, &mut rng).unwrap(); + + assert_eq!(split_output.key_packages.len(), 2); + + // Both packages required to reconstruct + let combined_secret = sss_combine_ed25519(&split_output.key_packages).unwrap(); + assert_eq!(combined_secret, secret); } +/// Test reshare: split to 3 nodes, then reshare to 3 different nodes. #[test] -fn test_expand_shares_duplicate_error() { - let mut secret = [0; 32]; - secret[31] = 1; - - let mut point_1 = [0; 32]; - point_1[31] = 1; - let mut point_2 = [0; 32]; - point_2[31] = 2; - let mut point_3 = [0; 32]; - point_3[31] = 3; +fn test_reshare_to_new_nodes() { + let mut secret = [0u8; 32]; + secret[0] = 77; + + // Original identifiers + let mut id_1 = [0u8; 32]; + id_1[0] = 1; + let mut id_2 = [0u8; 32]; + id_2[0] = 2; + let mut id_3 = [0u8; 32]; + id_3[0] = 3; let mut rng = OsRng; - let point_xs = vec![point_1, point_2, point_3]; - let split_points = sss_split_ed25519(secret, point_xs, 2, &mut rng).unwrap(); + // Initial split + let identifiers = vec![id_1, id_2, id_3]; + let split_output = sss_split_ed25519(secret, identifiers, 2, &mut rng).unwrap(); + + // New identifiers for reshare + let mut new_id_1 = [0u8; 32]; + new_id_1[0] = 4; + let mut new_id_2 = [0u8; 32]; + new_id_2[0] = 5; + let mut new_id_3 = [0u8; 32]; + new_id_3[0] = 6; + + let new_identifiers = vec![new_id_1, new_id_2, new_id_3]; + + // Reshare using first 2 key packages (threshold) + let key_packages_for_reshare: Vec<_> = + split_output.key_packages.iter().take(2).cloned().collect(); + let reshare_output = reshare(&key_packages_for_reshare, new_identifiers, 2, &mut rng).unwrap(); - // Try to expand with a hash that's already in split_points - let duplicate_hash = split_points[0].x; - let additional_hashes = vec![duplicate_hash]; - let result = expand_shares(split_points, additional_hashes, 2); + // Verify secret is preserved + assert_eq!(reshare_output.secret, secret); + assert_eq!(reshare_output.key_packages.len(), 3); - assert!(result.is_err()); - assert_eq!( - result.err().unwrap(), - "New hash is already included in split points" - ); + // Verify we can combine the reshared key packages to recover the secret + let combined_from_reshared = sss_combine_ed25519(&reshare_output.key_packages).unwrap(); + assert_eq!(combined_from_reshared, secret); } +/// Test reshare with same threshold. #[test] -fn test_expand_shares_preserves_original_points_order() { - let mut secret = [0; 32]; - secret[0] = 1; +fn test_reshare_2_of_2_to_2_of_2() { + let mut secret = [0u8; 32]; + secret[0] = 99; - let mut point_1 = [0; 32]; - point_1[0] = 1; - let mut point_2 = [0; 32]; - point_2[0] = 2; - let mut point_3 = [0; 32]; - point_3[0] = 3; + let mut id_1 = [0u8; 32]; + id_1[0] = 1; + let mut id_2 = [0u8; 32]; + id_2[0] = 2; let mut rng = OsRng; - let point_xs = vec![point_1, point_2, point_3]; - let split_points = sss_split_ed25519(secret, point_xs, 2, &mut rng).unwrap(); - - // Expand with additional nodes - let mut new_point_1 = [0; 32]; - new_point_1[0] = 4; - let mut new_point_2 = [0; 32]; - new_point_2[0] = 5; - - let additional_hashes = vec![new_point_1, new_point_2]; - let expand_result = expand_shares(split_points.clone(), additional_hashes, 2).unwrap(); - - // Verify original points are preserved in order at the beginning - for (i, original_point) in split_points.iter().enumerate() { - assert_eq!( - expand_result.reshared_points[i].x, original_point.x, - "Original point {} x coordinate mismatch", - i - ); - assert_eq!( - expand_result.reshared_points[i].y, original_point.y, - "Original point {} y coordinate mismatch", - i - ); - } - - // Verify the structure: first 3 are original, last 2 are new - assert_eq!(expand_result.reshared_points.len(), 5); - assert_eq!(expand_result.reshared_points[0], split_points[0]); - assert_eq!(expand_result.reshared_points[1], split_points[1]); - assert_eq!(expand_result.reshared_points[2], split_points[2]); - - // Verify new points have unique x values different from all other points - let new_points = &expand_result.reshared_points[3..]; - for (i, new_point) in new_points.iter().enumerate() { - // Check against original points - for (j, original_point) in split_points.iter().enumerate() { - assert_ne!( - new_point.x, original_point.x, - "New point {} has same x as original point {}", - i, j - ); - } - // Check against other new points - for (j, other_new_point) in new_points.iter().enumerate() { - if i != j { - assert_ne!( - new_point.x, other_new_point.x, - "New point {} has same x as new point {}", - i, j - ); - } - } - } + // Initial 2-of-2 split + let identifiers = vec![id_1, id_2]; + let split_output = sss_split_ed25519(secret, identifiers, 2, &mut rng).unwrap(); + + // New identifiers for reshare + let mut new_id_1 = [0u8; 32]; + new_id_1[0] = 10; + let mut new_id_2 = [0u8; 32]; + new_id_2[0] = 20; + + let new_identifiers = vec![new_id_1, new_id_2]; + + // Reshare (need both key packages for 2-of-2) + let reshare_output = reshare(&split_output.key_packages, new_identifiers, 2, &mut rng).unwrap(); + + assert_eq!(reshare_output.secret, secret); + assert_eq!(reshare_output.key_packages.len(), 2); + + // Combine reshared packages + let combined = sss_combine_ed25519(&reshare_output.key_packages).unwrap(); + assert_eq!(combined, secret); } diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/Cargo.toml b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/Cargo.toml index 86fa6f631..2f5d9a0cf 100644 --- a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/Cargo.toml +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/Cargo.toml @@ -31,6 +31,7 @@ serde_json = "1.0" gloo-utils = "0.2.0" rand_core = { version = "0.6", features = ["getrandom"] } getrandom = { version = "0.2", features = ["js"] } +hex = { workspace = true } # The `web-sys` crate allows you to interact with the various browser APIs, # like the DOM. diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keygen.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keygen.rs new file mode 100644 index 000000000..52c96cdfb --- /dev/null +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keygen.rs @@ -0,0 +1,110 @@ +use frost::keys::KeyPackage; +use frost_ed25519_keplr as frost; +use gloo_utils::format::JsValueSerdeExt; +use rand_core::OsRng; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; + +use crate::{KeyPackageRaw, PublicKeyPackageRaw}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CentralizedKeygenOutputRaw { + // pub private_key: [u8; 32], + // pub public_key: [u8; 32], + pub keygen_outputs: Vec, + pub public_key_package: PublicKeyPackageRaw, +} + +fn keygen_centralized_inner() -> Result { + let mut rng = OsRng; + let max_signers = 2; + let min_signers = 2; + + let (shares, pubkey_package) = frost::keys::generate_with_dealer( + max_signers, + min_signers, + frost::keys::IdentifierList::Default, + &mut rng, + ) + .map_err(|e| e.to_string())?; + + // let verifying_key = pubkey_package.verifying_key(); + // let public_key_bytes: [u8; 32] = verifying_key + // .serialize() + // .map_err(|e| e.to_string())? + // .try_into() + // .map_err(|_| "Invalid public key length")?; + + let public_key_package = PublicKeyPackageRaw::from_public_key_package(&pubkey_package)?; + + let mut keygen_outputs = Vec::with_capacity(shares.len()); + for (_identifier, secret_share) in shares { + let key_package = KeyPackage::try_from(secret_share).map_err(|e| e.to_string())?; + let key_package_raw = KeyPackageRaw::from_key_package(&key_package)?; + keygen_outputs.push(key_package_raw); + } + + Ok(CentralizedKeygenOutputRaw { + // private_key: [0u8; 32], + // public_key: public_key_bytes, + keygen_outputs, + public_key_package, + }) +} + +fn keygen_import_inner(secret: [u8; 32]) -> Result { + let mut rng = OsRng; + let max_signers = 2; + let min_signers = 2; + + let signing_key = frost::SigningKey::deserialize(&secret) + .map_err(|e| format!("Invalid secret key: {}", e))?; + + let (shares, pubkey_package) = frost::keys::split( + &signing_key, + max_signers, + min_signers, + frost::keys::IdentifierList::Default, + &mut rng, + ) + .map_err(|e| e.to_string())?; + + // let verifying_key = pubkey_package.verifying_key(); + // let public_key_bytes: [u8; 32] = verifying_key + // .serialize() + // .map_err(|e| e.to_string())? + // .try_into() + // .map_err(|_| "Invalid public key length")?; + + let public_key_package = PublicKeyPackageRaw::from_public_key_package(&pubkey_package)?; + + let mut keygen_outputs = Vec::with_capacity(shares.len()); + for (_identifier, secret_share) in shares { + let key_package = KeyPackage::try_from(secret_share).map_err(|e| e.to_string())?; + let key_package_raw = KeyPackageRaw::from_key_package(&key_package)?; + keygen_outputs.push(key_package_raw); + } + + Ok(CentralizedKeygenOutputRaw { + // private_key: secret, + // public_key: public_key_bytes, + keygen_outputs, + public_key_package, + }) +} + +#[wasm_bindgen] +pub fn cli_keygen_centralized_ed25519() -> Result { + let out = keygen_centralized_inner().map_err(|err| JsValue::from_str(&err))?; + JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) +} + +#[wasm_bindgen] +pub fn cli_keygen_import_ed25519(secret_key: JsValue) -> Result { + let secret: [u8; 32] = secret_key + .into_serde() + .map_err(|err| JsValue::from_str(&format!("Invalid secret key format: {}", err)))?; + + let out = keygen_import_inner(secret).map_err(|err| JsValue::from_str(&err))?; + JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) +} diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/identifier.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/identifier.rs new file mode 100644 index 000000000..d745ad34d --- /dev/null +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/identifier.rs @@ -0,0 +1,28 @@ +use serde::{Deserialize, Serialize}; + +pub type IdentifierHex = String; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IdentifierRaw { + pub bytes: [u8; 32], +} + +impl IdentifierRaw { + pub fn from_bytes(bytes: [u8; 32]) -> Self { + Self { bytes } + } + + pub fn from_hex(hex: String) -> Result { + let b = hex::decode(hex).unwrap(); + let bytes = b.try_into().map_err(|_| "Invalid hex length".to_string())?; + Ok(Self { bytes }) + } + + pub fn to_bytes(&self) -> [u8; 32] { + self.bytes.clone() + } + + pub fn to_string(&self) -> String { + hex::encode(self.bytes) + } +} diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/key_package.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/key_package.rs new file mode 100644 index 000000000..363592d40 --- /dev/null +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/key_package.rs @@ -0,0 +1,71 @@ +use frost_ed25519_keplr::keys::{KeyPackage, SigningShare, VerifyingShare}; +use frost_ed25519_keplr::{Identifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyPackageRaw { + pub identifier: [u8; 32], + pub signing_share: [u8; 32], + pub verifying_share: [u8; 32], + pub verifying_key: [u8; 32], + pub min_signers: u16, +} + +impl KeyPackageRaw { + pub fn from_key_package(pkg: &KeyPackage) -> Result { + let identifier: [u8; 32] = pkg + .identifier() + .serialize() + .try_into() + .map_err(|_| "Invalid identifier length")?; + + let signing_share: [u8; 32] = pkg + .signing_share() + .serialize() + .try_into() + .map_err(|_| "Invalid signing share length")?; + + let verifying_share: [u8; 32] = pkg + .verifying_share() + .serialize() + .map_err(|e| e.to_string())? + .try_into() + .map_err(|_| "Invalid verifying share length")?; + + let verifying_key: [u8; 32] = pkg + .verifying_key() + .serialize() + .map_err(|e| e.to_string())? + .try_into() + .map_err(|_| "Invalid verifying key length")?; + + Ok(Self { + identifier, + signing_share, + verifying_share, + verifying_key, + min_signers: *pkg.min_signers(), + }) + } + + pub fn to_key_package(&self) -> Result { + let identifier = Identifier::deserialize(&self.identifier).map_err(|e| e.to_string())?; + + let signing_share = + SigningShare::deserialize(&self.signing_share).map_err(|e| e.to_string())?; + + let verifying_share = + VerifyingShare::deserialize(&self.verifying_share).map_err(|e| e.to_string())?; + + let verifying_key = + VerifyingKey::deserialize(&self.verifying_key).map_err(|e| e.to_string())?; + + Ok(KeyPackage::new( + identifier, + signing_share, + verifying_share, + verifying_key, + self.min_signers, + )) + } +} diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/mod.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/mod.rs new file mode 100644 index 000000000..a14896e17 --- /dev/null +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/mod.rs @@ -0,0 +1,7 @@ +mod identifier; +mod key_package; +mod public_key_package; + +pub use identifier::*; +pub use key_package::*; +pub use public_key_package::*; diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/public_key_package.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/public_key_package.rs new file mode 100644 index 000000000..8d9ecb801 --- /dev/null +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/keys/public_key_package.rs @@ -0,0 +1,57 @@ +use frost_ed25519_keplr::keys::{PublicKeyPackage, VerifyingShare}; +use frost_ed25519_keplr::{Identifier, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; + +use super::IdentifierHex; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicKeyPackageRaw { + pub verifying_shares: HashMap, + pub verifying_key: [u8; 32], +} + +impl PublicKeyPackageRaw { + pub fn from_public_key_package(pkg: &PublicKeyPackage) -> Result { + let mut verifying_shares = HashMap::new(); + + for (identifier, verifying_share) in pkg.verifying_shares() { + let id_hex = hex::encode(identifier.serialize()); + let share_bytes: [u8; 32] = verifying_share + .serialize() + .map_err(|e| e.to_string())? + .try_into() + .map_err(|_| "Invalid verifying share length")?; + verifying_shares.insert(id_hex, share_bytes); + } + + let verifying_key: [u8; 32] = pkg + .verifying_key() + .serialize() + .map_err(|e| e.to_string())? + .try_into() + .map_err(|_| "Invalid verifying key length")?; + + Ok(Self { + verifying_shares, + verifying_key, + }) + } + + pub fn to_public_key_package(&self) -> Result { + let mut verifying_shares: BTreeMap = BTreeMap::new(); + + for (id_hex, share_bytes) in &self.verifying_shares { + let id_bytes = hex::decode(id_hex).map_err(|e| e.to_string())?; + let identifier = Identifier::deserialize(&id_bytes).map_err(|e| e.to_string())?; + let verifying_share = + VerifyingShare::deserialize(share_bytes).map_err(|e| e.to_string())?; + verifying_shares.insert(identifier, verifying_share); + } + + let verifying_key = + VerifyingKey::deserialize(&self.verifying_key).map_err(|e| e.to_string())?; + + Ok(PublicKeyPackage::new(verifying_shares, verifying_key)) + } +} diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/lib.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/lib.rs index bc9364566..76c7523e1 100644 --- a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/lib.rs +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/lib.rs @@ -1,9 +1,14 @@ -pub mod sss; +mod keygen; +mod keys; +mod sign; +mod sss; -use console_error_panic_hook; use std::sync::Once; use wasm_bindgen::prelude::*; +pub use keygen::*; +pub use keys::*; +pub use sign::*; pub use sss::*; // Ensure initialization happens only once diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sign.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sign.rs new file mode 100644 index 000000000..493bf2711 --- /dev/null +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sign.rs @@ -0,0 +1,243 @@ +use frost::keys::{KeyPackage, PublicKeyPackage}; +use frost::round1::{SigningCommitments, SigningNonces}; +use frost::round2::SignatureShare; +use frost::{Identifier, SigningPackage}; +use frost_ed25519_keplr as frost; +use gloo_utils::format::JsValueSerdeExt; +use rand_core::OsRng; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use wasm_bindgen::prelude::*; + +/// Output from a signing round 1 (commitment) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SigningCommitmentOutput { + pub nonces: Vec, + pub commitments: Vec, + pub identifier: Vec, +} + +/// Output from a signing round 2 (signature share) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureShareOutput { + pub signature_share: Vec, + pub identifier: Vec, +} + +/// Final aggregated signature +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureOutput { + pub signature: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct SignRound2Input { + pub message: Vec, + pub key_package: Vec, + pub nonces: Vec, + pub all_commitments: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct CommitmentEntry { + pub identifier: Vec, + pub commitments: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct AggregateInput { + pub message: Vec, + pub all_commitments: Vec, + pub all_signature_shares: Vec, + pub public_key_package: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct SignatureShareEntry { + pub identifier: Vec, + pub signature_share: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct VerifyInput { + pub message: Vec, + pub signature: Vec, + pub public_key_package: Vec, +} + +fn sign_round1_inner(key_package_bytes: &[u8]) -> Result { + let mut rng = OsRng; + + let key_package = KeyPackage::deserialize(key_package_bytes).map_err(|e| e.to_string())?; + + let (nonces, commitments) = frost::round1::commit(key_package.signing_share(), &mut rng); + + let nonces_bytes = nonces.serialize().map_err(|e| e.to_string())?; + let commitments_bytes = commitments.serialize().map_err(|e| e.to_string())?; + let identifier_bytes = key_package.identifier().serialize().to_vec(); + + Ok(SigningCommitmentOutput { + nonces: nonces_bytes, + commitments: commitments_bytes, + identifier: identifier_bytes, + }) +} + +fn sign_round2_inner( + message: &[u8], + key_package_bytes: &[u8], + nonces_bytes: &[u8], + all_commitments: &[(Vec, Vec)], +) -> Result { + let key_package = KeyPackage::deserialize(key_package_bytes).map_err(|e| e.to_string())?; + + let nonces = SigningNonces::deserialize(nonces_bytes).map_err(|e| e.to_string())?; + + let mut commitments_map: BTreeMap = BTreeMap::new(); + for (id_bytes, comm_bytes) in all_commitments { + let identifier = Identifier::deserialize(id_bytes).map_err(|e| e.to_string())?; + let commitments = SigningCommitments::deserialize(comm_bytes).map_err(|e| e.to_string())?; + commitments_map.insert(identifier, commitments); + } + + let signing_package = SigningPackage::new(commitments_map, message); + + let signature_share = + frost::round2::sign(&signing_package, &nonces, &key_package).map_err(|e| e.to_string())?; + + let signature_share_bytes = signature_share.serialize(); + let identifier_bytes = key_package.identifier().serialize().to_vec(); + + Ok(SignatureShareOutput { + signature_share: signature_share_bytes, + identifier: identifier_bytes, + }) +} + +fn aggregate_inner( + message: &[u8], + all_commitments: &[(Vec, Vec)], + all_signature_shares: &[(Vec, Vec)], + public_key_package_bytes: &[u8], +) -> Result { + let pubkey_package = + PublicKeyPackage::deserialize(public_key_package_bytes).map_err(|e| e.to_string())?; + + let mut commitments_map: BTreeMap = BTreeMap::new(); + for (id_bytes, comm_bytes) in all_commitments { + let identifier = Identifier::deserialize(id_bytes).map_err(|e| e.to_string())?; + let commitments = SigningCommitments::deserialize(comm_bytes).map_err(|e| e.to_string())?; + commitments_map.insert(identifier, commitments); + } + + let signing_package = SigningPackage::new(commitments_map, message); + + let mut signature_shares: BTreeMap = BTreeMap::new(); + for (id_bytes, share_bytes) in all_signature_shares { + let identifier = Identifier::deserialize(id_bytes).map_err(|e| e.to_string())?; + let share = SignatureShare::deserialize(share_bytes).map_err(|e| e.to_string())?; + signature_shares.insert(identifier, share); + } + + let signature = frost::aggregate(&signing_package, &signature_shares, &pubkey_package) + .map_err(|e| e.to_string())?; + + let signature_bytes = signature.serialize().map_err(|e| e.to_string())?; + + Ok(SignatureOutput { + signature: signature_bytes, + }) +} + +fn verify_inner( + message: &[u8], + signature_bytes: &[u8], + public_key_package_bytes: &[u8], +) -> Result { + let pubkey_package = + PublicKeyPackage::deserialize(public_key_package_bytes).map_err(|e| e.to_string())?; + + let signature_array: [u8; 64] = signature_bytes + .try_into() + .map_err(|_| "Invalid signature length".to_string())?; + + let signature = frost::Signature::deserialize(&signature_array).map_err(|e| e.to_string())?; + + let verifying_key = pubkey_package.verifying_key(); + match verifying_key.verify(message, &signature) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } +} + +#[wasm_bindgen] +pub fn cli_sign_round1_ed25519(key_package: JsValue) -> Result { + let key_package_bytes: Vec = key_package + .into_serde() + .map_err(|err| JsValue::from_str(&format!("Invalid key_package format: {}", err)))?; + + let out = sign_round1_inner(&key_package_bytes).map_err(|err| JsValue::from_str(&err))?; + JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) +} + +#[wasm_bindgen] +pub fn cli_sign_round2_ed25519(input: JsValue) -> Result { + let input: SignRound2Input = input + .into_serde() + .map_err(|err| JsValue::from_str(&format!("Invalid input format: {}", err)))?; + + let all_commitments: Vec<(Vec, Vec)> = input + .all_commitments + .into_iter() + .map(|c| (c.identifier, c.commitments)) + .collect(); + + let out = sign_round2_inner( + &input.message, + &input.key_package, + &input.nonces, + &all_commitments, + ) + .map_err(|err| JsValue::from_str(&err))?; + + JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) +} + +#[wasm_bindgen] +pub fn cli_aggregate_ed25519(input: JsValue) -> Result { + let input: AggregateInput = input + .into_serde() + .map_err(|err| JsValue::from_str(&format!("Invalid input format: {}", err)))?; + + let all_commitments: Vec<(Vec, Vec)> = input + .all_commitments + .into_iter() + .map(|c| (c.identifier, c.commitments)) + .collect(); + + let all_signature_shares: Vec<(Vec, Vec)> = input + .all_signature_shares + .into_iter() + .map(|s| (s.identifier, s.signature_share)) + .collect(); + + let out = aggregate_inner( + &input.message, + &all_commitments, + &all_signature_shares, + &input.public_key_package, + ) + .map_err(|err| JsValue::from_str(&err))?; + + JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) +} + +#[wasm_bindgen] +pub fn cli_verify_ed25519(input: JsValue) -> Result { + let input: VerifyInput = input + .into_serde() + .map_err(|err| JsValue::from_str(&format!("Invalid input format: {}", err)))?; + + verify_inner(&input.message, &input.signature, &input.public_key_package) + .map_err(|err| JsValue::from_str(&err)) +} diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/combine.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/combine.rs index 5618809de..42e02ec2a 100644 --- a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/combine.rs +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/combine.rs @@ -1,14 +1,29 @@ -use frost_ed25519_keplr::{sss_combine_ed25519, Point256}; +use frost_ed25519_keplr::sss_combine_ed25519; use gloo_utils::format::JsValueSerdeExt; use wasm_bindgen::prelude::*; +use crate::KeyPackageRaw; + +/// Combines Ed25519 key packages to recover the original secret. +/// +/// Takes an array of KeyPackageRaw objects and uses FROST's reconstruct +/// to recover the original signing key via Lagrange interpolation. +/// +/// NOTE: The caller must provide at least `min_signers` key packages. #[wasm_bindgen] -pub fn sss_combine(points: JsValue, t: u32) -> Result { - let points: Vec = points +pub fn sss_combine(key_packages: JsValue) -> Result { + let key_packages_raw: Vec = key_packages .into_serde() - .map_err(|err| JsValue::from_str(&err.to_string()))?; + .map_err(|err| JsValue::from_str(&format!("Invalid key_packages format: {}", err)))?; + + let key_packages: Vec<_> = key_packages_raw + .iter() + .map(|kp| kp.to_key_package()) + .collect::, _>>() + .map_err(|err| JsValue::from_str(&err))?; - let out = sss_combine_ed25519(points, t).map_err(|err| JsValue::from_str(&err.to_string()))?; + let secret = sss_combine_ed25519(&key_packages) + .map_err(|err| JsValue::from_str(&err))?; - JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) + JsValue::from_serde(&secret).map_err(|err| JsValue::from_str(&err.to_string())) } diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/reshare.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/reshare.rs index 608cff373..59b8664ab 100644 --- a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/reshare.rs +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/reshare.rs @@ -1,39 +1,62 @@ -use frost_ed25519_keplr::{expand_shares, reshare, Point256, ReshareResult}; +use frost_ed25519_keplr::reshare; use gloo_utils::format::JsValueSerdeExt; use rand_core::OsRng; +use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; -#[wasm_bindgen] -pub fn sss_reshare(points: JsValue, ks_node_hashes: JsValue, t: u32) -> Result { - let points: Vec = points - .into_serde() - .map_err(|err| JsValue::from_str(&err.to_string()))?; - let ks_node_hashes: Vec<[u8; 32]> = ks_node_hashes - .into_serde() - .map_err(|err| JsValue::from_str(&err.to_string()))?; +use crate::{KeyPackageRaw, PublicKeyPackageRaw}; - let mut rng = OsRng; - let out: ReshareResult = reshare(points, ks_node_hashes, t, &mut rng) - .map_err(|err| JsValue::from_str(&err.to_string()))?; - - JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReshareOutputRaw { + pub key_packages: Vec, + pub public_key_package: PublicKeyPackageRaw, + pub secret: [u8; 32], } +/// Reshares existing key packages to a new set of nodes with a fresh polynomial. +/// +/// Takes existing KeyPackageRaw objects, recovers the secret, and creates new +/// KeyPackages for the new identifiers. #[wasm_bindgen] -pub fn sss_expand_shares( - points: JsValue, - additional_ks_node_hashes: JsValue, - t: u32, +pub fn sss_reshare( + key_packages: JsValue, + new_identifiers: JsValue, + new_min_signers: u16, ) -> Result { - let points: Vec = points + let key_packages_raw: Vec = key_packages .into_serde() - .map_err(|err| JsValue::from_str(&err.to_string()))?; - let additional_ks_node_hashes: Vec<[u8; 32]> = additional_ks_node_hashes + .map_err(|err| JsValue::from_str(&format!("Invalid key_packages format: {}", err)))?; + + let new_identifiers: Vec<[u8; 32]> = new_identifiers .into_serde() - .map_err(|err| JsValue::from_str(&err.to_string()))?; + .map_err(|err| JsValue::from_str(&format!("Invalid new_identifiers format: {}", err)))?; + + let key_packages: Vec<_> = key_packages_raw + .iter() + .map(|kp| kp.to_key_package()) + .collect::, _>>() + .map_err(|err| JsValue::from_str(&err))?; + + let mut rng = OsRng; + let reshare_output = reshare(&key_packages, new_identifiers, new_min_signers, &mut rng) + .map_err(|err| JsValue::from_str(&err))?; + + let key_packages_raw: Vec = reshare_output + .key_packages + .iter() + .map(|kp| KeyPackageRaw::from_key_package(kp)) + .collect::, _>>() + .map_err(|err| JsValue::from_str(&err))?; + + let public_key_package = + PublicKeyPackageRaw::from_public_key_package(&reshare_output.public_key_package) + .map_err(|err| JsValue::from_str(&err))?; - let out: ReshareResult = expand_shares(points, additional_ks_node_hashes, t) - .map_err(|err| JsValue::from_str(&err.to_string()))?; + let result = ReshareOutputRaw { + key_packages: key_packages_raw, + public_key_package, + secret: reshare_output.secret, + }; - JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) + JsValue::from_serde(&result).map_err(|err| JsValue::from_str(&err.to_string())) } diff --git a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/split.rs b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/split.rs index b4a278928..7be1a57ee 100644 --- a/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/split.rs +++ b/crypto/teddsa/frost_ed25519_keplr_wasm/wasm/src/sss/split.rs @@ -1,20 +1,55 @@ -use frost_ed25519_keplr::{sss_split_ed25519, Point256}; +use frost_ed25519_keplr::sss_split_ed25519; use gloo_utils::format::JsValueSerdeExt; use rand_core::OsRng; +use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; +use crate::{KeyPackageRaw, PublicKeyPackageRaw}; + +/// Output of the split operation for WASM. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SplitOutputRaw { + /// Key packages for each participant + pub key_packages: Vec, + /// The public key package shared among all participants + pub public_key_package: PublicKeyPackageRaw, +} + +/// Splits an Ed25519 secret into FROST key packages using VSS. +/// +/// Returns KeyPackages that can be used directly for FROST signing, +/// along with a PublicKeyPackage. #[wasm_bindgen] -pub fn sss_split(secret: JsValue, point_xs: JsValue, t: u32) -> Result { +pub fn sss_split( + secret: JsValue, + identifiers: JsValue, + min_signers: u16, +) -> Result { let secret: [u8; 32] = secret .into_serde() - .map_err(|err| JsValue::from_str(&err.to_string()))?; - let point_xs: Vec<[u8; 32]> = point_xs + .map_err(|err| JsValue::from_str(&format!("Invalid secret format: {}", err)))?; + let identifiers: Vec<[u8; 32]> = identifiers .into_serde() - .map_err(|err| JsValue::from_str(&err.to_string()))?; + .map_err(|err| JsValue::from_str(&format!("Invalid identifiers format: {}", err)))?; let mut rng = OsRng; - let out: Vec = sss_split_ed25519(secret, point_xs, t, &mut rng) - .map_err(|err| JsValue::from_str(&err.to_string()))?; + let output = sss_split_ed25519(secret, identifiers, min_signers, &mut rng) + .map_err(|err| JsValue::from_str(&err))?; + + let key_packages: Vec = output + .key_packages + .iter() + .map(|kp| KeyPackageRaw::from_key_package(kp)) + .collect::, _>>() + .map_err(|err| JsValue::from_str(&err))?; + + let public_key_package = PublicKeyPackageRaw::from_public_key_package(&output.public_key_package) + .map_err(|err| JsValue::from_str(&err))?; + + let result = SplitOutputRaw { + key_packages, + public_key_package, + }; - JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) + JsValue::from_serde(&result).map_err(|err| JsValue::from_str(&err.to_string())) } diff --git a/crypto/teddsa/teddsa_addon/addon/Cargo.toml b/crypto/teddsa/teddsa_addon/addon/Cargo.toml new file mode 100644 index 000000000..98a97861a --- /dev/null +++ b/crypto/teddsa/teddsa_addon/addon/Cargo.toml @@ -0,0 +1,26 @@ +[package] +edition = "2021" +name = "teddsa_addon" +version = "0.0.1" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix +napi = { version = "2.12.2", default-features = false, features = [ + "napi4", + "serde-json" +] } +napi-derive = "2.12.2" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +frost_ed25519_keplr = { workspace = true, features = ["serialization"] } +rand_core = { workspace = true, features = ["getrandom"] } + +[build-dependencies] +napi-build = "2.0.1" + +[profile.release] +lto = true +strip = "symbols" diff --git a/crypto/teddsa/teddsa_addon/addon/build.rs b/crypto/teddsa/teddsa_addon/addon/build.rs new file mode 100644 index 000000000..9fc236788 --- /dev/null +++ b/crypto/teddsa/teddsa_addon/addon/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/crypto/teddsa/teddsa_addon/addon/index.d.ts b/crypto/teddsa/teddsa_addon/addon/index.d.ts new file mode 100644 index 000000000..41421d5da --- /dev/null +++ b/crypto/teddsa/teddsa_addon/addon/index.d.ts @@ -0,0 +1,67 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +/** + * Generate a 2-of-2 threshold Ed25519 key using centralized key generation. + * + * Returns a JSON object containing: + * - `keygen_outputs`: Array of two key shares + * - `public_key`: The Ed25519 public key (32 bytes) + * - `private_key`: The original private key (for backup purposes only) + */ +export declare function napiKeygenCentralizedEd25519(): any +/** + * Import an existing Ed25519 secret key and split it into threshold shares. + * + * # Arguments + * * `secret_key` - A 32-byte array representing the Ed25519 secret key + */ +export declare function napiKeygenImportEd25519(secretKey: Array): any +/** + * Round 1: Generate signing commitments for a participant. + * + * # Arguments + * * `key_package` - The participant's serialized KeyPackage + * + * Returns a JSON object containing: + * - `nonces`: Serialized nonces (keep secret, use in round 2) + * - `commitments`: Serialized commitments (send to coordinator) + * - `identifier`: Participant identifier + */ +export declare function napiSignRound1Ed25519(keyPackage: Array): any +/** + * Round 2: Generate a signature share for a participant. + * + * # Arguments + * * `message` - Message to sign + * * `key_package` - The participant's serialized KeyPackage + * * `nonces` - Nonces from round 1 + * * `all_commitments` - JSON array of CommitmentEntry objects + */ +export declare function napiSignRound2Ed25519(message: Array, keyPackage: Array, nonces: Array, allCommitments: any): any +/** + * Aggregate signature shares into a final threshold signature. + * + * # Arguments + * * `message` - Message that was signed + * * `all_commitments` - JSON array of CommitmentEntry objects + * * `all_signature_shares` - JSON array of SignatureShareEntry objects + * * `public_key_package` - Serialized PublicKeyPackage + * + * Returns a JSON object containing: + * - `signature`: The 64-byte Ed25519 signature + */ +export declare function napiAggregateEd25519(message: Array, allCommitments: any, allSignatureShares: any, publicKeyPackage: Array): any +/** + * Verify a signature against a public key. + * + * # Arguments + * * `message` - Original message + * * `signature` - 64-byte Ed25519 signature + * * `public_key_package` - Serialized PublicKeyPackage + * + * Returns `true` if the signature is valid, `false` otherwise. + */ +export declare function napiVerifyEd25519(message: Array, signature: Array, publicKeyPackage: Array): boolean diff --git a/crypto/teddsa/teddsa_addon/addon/index.js b/crypto/teddsa/teddsa_addon/addon/index.js new file mode 100644 index 000000000..34633d3ff --- /dev/null +++ b/crypto/teddsa/teddsa_addon/addon/index.js @@ -0,0 +1,320 @@ +/* tslint:disable */ +/* eslint-disable */ +/* prettier-ignore */ + +/* auto-generated by NAPI-RS */ + +const { existsSync, readFileSync } = require('fs') +const { join } = require('path') + +const { platform, arch } = process + +let nativeBinding = null +let localFileExisted = false +let loadError = null + +function isMusl() { + // For Node 10 + if (!process.report || typeof process.report.getReport !== 'function') { + try { + const lddPath = require('child_process').execSync('which ldd').toString().trim() + return readFileSync(lddPath, 'utf8').includes('musl') + } catch (e) { + return true + } + } else { + const { glibcVersionRuntime } = process.report.getReport().header + return !glibcVersionRuntime + } +} + +switch (platform) { + case 'android': + switch (arch) { + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'teddsa-addon.android-arm64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.android-arm64.node') + } else { + nativeBinding = require('teddsa-addon-android-arm64') + } + } catch (e) { + loadError = e + } + break + case 'arm': + localFileExisted = existsSync(join(__dirname, 'teddsa-addon.android-arm-eabi.node')) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.android-arm-eabi.node') + } else { + nativeBinding = require('teddsa-addon-android-arm-eabi') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Android ${arch}`) + } + break + case 'win32': + switch (arch) { + case 'x64': + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.win32-x64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.win32-x64-msvc.node') + } else { + nativeBinding = require('teddsa-addon-win32-x64-msvc') + } + } catch (e) { + loadError = e + } + break + case 'ia32': + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.win32-ia32-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.win32-ia32-msvc.node') + } else { + nativeBinding = require('teddsa-addon-win32-ia32-msvc') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.win32-arm64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.win32-arm64-msvc.node') + } else { + nativeBinding = require('teddsa-addon-win32-arm64-msvc') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Windows: ${arch}`) + } + break + case 'darwin': + localFileExisted = existsSync(join(__dirname, 'teddsa-addon.darwin-universal.node')) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.darwin-universal.node') + } else { + nativeBinding = require('teddsa-addon-darwin-universal') + } + break + } catch {} + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'teddsa-addon.darwin-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.darwin-x64.node') + } else { + nativeBinding = require('teddsa-addon-darwin-x64') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.darwin-arm64.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.darwin-arm64.node') + } else { + nativeBinding = require('teddsa-addon-darwin-arm64') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on macOS: ${arch}`) + } + break + case 'freebsd': + if (arch !== 'x64') { + throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) + } + localFileExisted = existsSync(join(__dirname, 'teddsa-addon.freebsd-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.freebsd-x64.node') + } else { + nativeBinding = require('teddsa-addon-freebsd-x64') + } + } catch (e) { + loadError = e + } + break + case 'linux': + switch (arch) { + case 'x64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.linux-x64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.linux-x64-musl.node') + } else { + nativeBinding = require('teddsa-addon-linux-x64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.linux-x64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.linux-x64-gnu.node') + } else { + nativeBinding = require('teddsa-addon-linux-x64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.linux-arm64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.linux-arm64-musl.node') + } else { + nativeBinding = require('teddsa-addon-linux-arm64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.linux-arm64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.linux-arm64-gnu.node') + } else { + nativeBinding = require('teddsa-addon-linux-arm64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.linux-arm-musleabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.linux-arm-musleabihf.node') + } else { + nativeBinding = require('teddsa-addon-linux-arm-musleabihf') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.linux-arm-gnueabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.linux-arm-gnueabihf.node') + } else { + nativeBinding = require('teddsa-addon-linux-arm-gnueabihf') + } + } catch (e) { + loadError = e + } + } + break + case 'riscv64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.linux-riscv64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.linux-riscv64-musl.node') + } else { + nativeBinding = require('teddsa-addon-linux-riscv64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.linux-riscv64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.linux-riscv64-gnu.node') + } else { + nativeBinding = require('teddsa-addon-linux-riscv64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 's390x': + localFileExisted = existsSync( + join(__dirname, 'teddsa-addon.linux-s390x-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./teddsa-addon.linux-s390x-gnu.node') + } else { + nativeBinding = require('teddsa-addon-linux-s390x-gnu') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Linux: ${arch}`) + } + break + default: + throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) +} + +if (!nativeBinding) { + if (loadError) { + throw loadError + } + throw new Error(`Failed to load native binding`) +} + +const { napiKeygenCentralizedEd25519, napiKeygenImportEd25519, napiSignRound1Ed25519, napiSignRound2Ed25519, napiAggregateEd25519, napiVerifyEd25519 } = nativeBinding + +module.exports.napiKeygenCentralizedEd25519 = napiKeygenCentralizedEd25519 +module.exports.napiKeygenImportEd25519 = napiKeygenImportEd25519 +module.exports.napiSignRound1Ed25519 = napiSignRound1Ed25519 +module.exports.napiSignRound2Ed25519 = napiSignRound2Ed25519 +module.exports.napiAggregateEd25519 = napiAggregateEd25519 +module.exports.napiVerifyEd25519 = napiVerifyEd25519 diff --git a/crypto/teddsa/teddsa_addon/addon/package.json b/crypto/teddsa/teddsa_addon/addon/package.json new file mode 100644 index 000000000..46f8b8b52 --- /dev/null +++ b/crypto/teddsa/teddsa_addon/addon/package.json @@ -0,0 +1,31 @@ +{ + "name": "@oko-wallet/teddsa-addon-native", + "private": true, + "version": "0.0.1", + "main": "index.js", + "types": "index.d.ts", + "napi": { + "name": "teddsa-addon", + "triples": {} + }, + "license": "MIT", + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "ava": "^6.0.1" + }, + "ava": { + "timeout": "3m" + }, + "engines": { + "node": ">= 10" + }, + "scripts": { + "artifacts": "napi artifacts", + "build": "napi build --platform --release", + "build:debug": "napi build --platform", + "prepublishOnly": "napi prepublish -t npm", + "test": "ava", + "universal": "napi universal", + "version": "napi version" + } +} diff --git a/crypto/teddsa/teddsa_addon/addon/src/keygen.rs b/crypto/teddsa/teddsa_addon/addon/src/keygen.rs new file mode 100644 index 000000000..2e33ef085 --- /dev/null +++ b/crypto/teddsa/teddsa_addon/addon/src/keygen.rs @@ -0,0 +1,146 @@ +use frost::keys::KeyPackage; +use frost_ed25519_keplr as frost; +use napi::bindgen_prelude::*; +use napi_derive::napi; +use rand_core::OsRng; +use serde::{Deserialize, Serialize}; + +/// Output from centralized key generation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CentralizedKeygenOutput { + pub private_key: Vec, + pub keygen_outputs: Vec, + pub public_key: Vec, +} + +/// A single participant's key share +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeygenOutput { + pub key_package: Vec, + pub public_key_package: Vec, + pub identifier: Vec, +} + +fn keygen_centralized_inner() -> std::result::Result { + let mut rng = OsRng; + let max_signers = 2; + let min_signers = 2; + + let (shares, pubkey_package) = frost::keys::generate_with_dealer( + max_signers, + min_signers, + frost::keys::IdentifierList::Default, + &mut rng, + ) + .map_err(|e| e.to_string())?; + + let pubkey_package_bytes = pubkey_package.serialize().map_err(|e| e.to_string())?; + let verifying_key = pubkey_package.verifying_key(); + let public_key_bytes = verifying_key.serialize().map_err(|e| e.to_string())?; + + let mut keygen_outputs = Vec::with_capacity(shares.len()); + for (identifier, secret_share) in shares { + let key_package = KeyPackage::try_from(secret_share).map_err(|e| e.to_string())?; + let key_package_bytes = key_package.serialize().map_err(|e| e.to_string())?; + let identifier_bytes = identifier.serialize().to_vec(); + + keygen_outputs.push(KeygenOutput { + key_package: key_package_bytes, + public_key_package: pubkey_package_bytes.clone(), + identifier: identifier_bytes, + }); + } + + Ok(CentralizedKeygenOutput { + private_key: vec![0u8; 32], + keygen_outputs, + public_key: public_key_bytes, + }) +} + +fn keygen_import_inner(secret: [u8; 32]) -> std::result::Result { + let mut rng = OsRng; + let max_signers = 2; + let min_signers = 2; + + let signing_key = frost::SigningKey::deserialize(&secret) + .map_err(|e| format!("Invalid secret key: {}", e))?; + + let (shares, pubkey_package) = frost::keys::split( + &signing_key, + max_signers, + min_signers, + frost::keys::IdentifierList::Default, + &mut rng, + ) + .map_err(|e| e.to_string())?; + + let pubkey_package_bytes = pubkey_package.serialize().map_err(|e| e.to_string())?; + let verifying_key = pubkey_package.verifying_key(); + let public_key_bytes = verifying_key.serialize().map_err(|e| e.to_string())?; + + let mut keygen_outputs = Vec::with_capacity(shares.len()); + for (identifier, secret_share) in shares { + let key_package = KeyPackage::try_from(secret_share).map_err(|e| e.to_string())?; + let key_package_bytes = key_package.serialize().map_err(|e| e.to_string())?; + let identifier_bytes = identifier.serialize().to_vec(); + + keygen_outputs.push(KeygenOutput { + key_package: key_package_bytes, + public_key_package: pubkey_package_bytes.clone(), + identifier: identifier_bytes, + }); + } + + Ok(CentralizedKeygenOutput { + private_key: secret.to_vec(), + keygen_outputs, + public_key: public_key_bytes, + }) +} + +/// Generate a 2-of-2 threshold Ed25519 key using centralized key generation. +#[napi] +pub fn napi_keygen_centralized_ed25519() -> Result { + let output = keygen_centralized_inner().map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("keygen_centralized error: {:?}", e), + ) + })?; + + serde_json::to_value(output).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("serialization error: {:?}", e), + ) + }) +} + +/// Import an existing Ed25519 secret key and split it into threshold shares. +#[napi] +pub fn napi_keygen_import_ed25519(secret_key: Vec) -> Result { + if secret_key.len() != 32 { + return Err(napi::Error::new( + napi::Status::InvalidArg, + "secret_key must be exactly 32 bytes", + )); + } + + let mut secret_arr = [0u8; 32]; + secret_arr.copy_from_slice(&secret_key); + + let output = keygen_import_inner(secret_arr).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("keygen_import error: {:?}", e), + ) + })?; + + serde_json::to_value(output).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("serialization error: {:?}", e), + ) + }) +} diff --git a/crypto/teddsa/teddsa_addon/addon/src/lib.rs b/crypto/teddsa/teddsa_addon/addon/src/lib.rs new file mode 100644 index 000000000..fdca68826 --- /dev/null +++ b/crypto/teddsa/teddsa_addon/addon/src/lib.rs @@ -0,0 +1,5 @@ +mod keygen; +mod sign; + +pub use keygen::*; +pub use sign::*; diff --git a/crypto/teddsa/teddsa_addon/addon/src/sign.rs b/crypto/teddsa/teddsa_addon/addon/src/sign.rs new file mode 100644 index 000000000..8bf526117 --- /dev/null +++ b/crypto/teddsa/teddsa_addon/addon/src/sign.rs @@ -0,0 +1,277 @@ +use frost::keys::{KeyPackage, PublicKeyPackage}; +use frost::round1::{SigningCommitments, SigningNonces}; +use frost::round2::SignatureShare; +use frost::{Identifier, SigningPackage}; +use frost_ed25519_keplr as frost; +use napi::bindgen_prelude::*; +use napi_derive::napi; +use rand_core::OsRng; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Output from a signing round 1 (commitment) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SigningCommitmentOutput { + pub nonces: Vec, + pub commitments: Vec, + pub identifier: Vec, +} + +/// Output from a signing round 2 (signature share) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureShareOutput { + pub signature_share: Vec, + pub identifier: Vec, +} + +/// Final aggregated signature +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureOutput { + pub signature: Vec, +} + +/// Commitment entry for signing +#[derive(Serialize, Deserialize)] +pub struct CommitmentEntry { + pub identifier: Vec, + pub commitments: Vec, +} + +/// Signature share entry for aggregation +#[derive(Serialize, Deserialize)] +pub struct SignatureShareEntry { + pub identifier: Vec, + pub signature_share: Vec, +} + +fn sign_round1_inner( + key_package_bytes: &[u8], +) -> std::result::Result { + let mut rng = OsRng; + + let key_package = KeyPackage::deserialize(key_package_bytes).map_err(|e| e.to_string())?; + + let (nonces, commitments) = frost::round1::commit(key_package.signing_share(), &mut rng); + + let nonces_bytes = nonces.serialize().map_err(|e| e.to_string())?; + let commitments_bytes = commitments.serialize().map_err(|e| e.to_string())?; + let identifier_bytes = key_package.identifier().serialize().to_vec(); + + Ok(SigningCommitmentOutput { + nonces: nonces_bytes, + commitments: commitments_bytes, + identifier: identifier_bytes, + }) +} + +fn sign_round2_inner( + message: &[u8], + key_package_bytes: &[u8], + nonces_bytes: &[u8], + all_commitments: &[(Vec, Vec)], +) -> std::result::Result { + let key_package = KeyPackage::deserialize(key_package_bytes).map_err(|e| e.to_string())?; + + let nonces = SigningNonces::deserialize(nonces_bytes).map_err(|e| e.to_string())?; + + let mut commitments_map: BTreeMap = BTreeMap::new(); + for (id_bytes, comm_bytes) in all_commitments { + let identifier = Identifier::deserialize(id_bytes).map_err(|e| e.to_string())?; + let commitments = SigningCommitments::deserialize(comm_bytes).map_err(|e| e.to_string())?; + commitments_map.insert(identifier, commitments); + } + + let signing_package = SigningPackage::new(commitments_map, message); + + let signature_share = + frost::round2::sign(&signing_package, &nonces, &key_package).map_err(|e| e.to_string())?; + + let signature_share_bytes = signature_share.serialize(); + let identifier_bytes = key_package.identifier().serialize().to_vec(); + + Ok(SignatureShareOutput { + signature_share: signature_share_bytes, + identifier: identifier_bytes, + }) +} + +fn aggregate_inner( + message: &[u8], + all_commitments: &[(Vec, Vec)], + all_signature_shares: &[(Vec, Vec)], + public_key_package_bytes: &[u8], +) -> std::result::Result { + let pubkey_package = + PublicKeyPackage::deserialize(public_key_package_bytes).map_err(|e| e.to_string())?; + + let mut commitments_map: BTreeMap = BTreeMap::new(); + for (id_bytes, comm_bytes) in all_commitments { + let identifier = Identifier::deserialize(id_bytes).map_err(|e| e.to_string())?; + let commitments = SigningCommitments::deserialize(comm_bytes).map_err(|e| e.to_string())?; + commitments_map.insert(identifier, commitments); + } + + let signing_package = SigningPackage::new(commitments_map, message); + + let mut signature_shares: BTreeMap = BTreeMap::new(); + for (id_bytes, share_bytes) in all_signature_shares { + let identifier = Identifier::deserialize(id_bytes).map_err(|e| e.to_string())?; + let share = SignatureShare::deserialize(share_bytes).map_err(|e| e.to_string())?; + signature_shares.insert(identifier, share); + } + + let signature = frost::aggregate(&signing_package, &signature_shares, &pubkey_package) + .map_err(|e| e.to_string())?; + + let signature_bytes = signature.serialize().map_err(|e| e.to_string())?; + + Ok(SignatureOutput { + signature: signature_bytes, + }) +} + +fn verify_inner( + message: &[u8], + signature_bytes: &[u8], + public_key_package_bytes: &[u8], +) -> std::result::Result { + let pubkey_package = + PublicKeyPackage::deserialize(public_key_package_bytes).map_err(|e| e.to_string())?; + + let signature_array: [u8; 64] = signature_bytes + .try_into() + .map_err(|_| "Invalid signature length".to_string())?; + + let signature = frost::Signature::deserialize(&signature_array).map_err(|e| e.to_string())?; + + let verifying_key = pubkey_package.verifying_key(); + match verifying_key.verify(message, &signature) { + Ok(_) => Ok(true), + Err(_) => Ok(false), + } +} + +/// Round 1: Generate signing commitments for a participant. +#[napi] +pub fn napi_sign_round1_ed25519(key_package: Vec) -> Result { + let output = sign_round1_inner(&key_package).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("sign_round1 error: {:?}", e), + ) + })?; + + serde_json::to_value(output).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("serialization error: {:?}", e), + ) + }) +} + +/// Round 2: Generate a signature share for a participant. +#[napi] +pub fn napi_sign_round2_ed25519( + message: Vec, + key_package: Vec, + nonces: Vec, + all_commitments: serde_json::Value, +) -> Result { + let commitments: Vec = + serde_json::from_value(all_commitments).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("deserialization error (all_commitments): {:?}", e), + ) + })?; + + let commitments_vec: Vec<(Vec, Vec)> = commitments + .into_iter() + .map(|c| (c.identifier, c.commitments)) + .collect(); + + let output = + sign_round2_inner(&message, &key_package, &nonces, &commitments_vec).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("sign_round2 error: {:?}", e), + ) + })?; + + serde_json::to_value(output).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("serialization error: {:?}", e), + ) + }) +} + +/// Aggregate signature shares into a final threshold signature. +#[napi] +pub fn napi_aggregate_ed25519( + message: Vec, + all_commitments: serde_json::Value, + all_signature_shares: serde_json::Value, + public_key_package: Vec, +) -> Result { + let commitments: Vec = + serde_json::from_value(all_commitments).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("deserialization error (all_commitments): {:?}", e), + ) + })?; + + let sig_shares: Vec = serde_json::from_value(all_signature_shares) + .map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("deserialization error (all_signature_shares): {:?}", e), + ) + })?; + + let commitments_vec: Vec<(Vec, Vec)> = commitments + .into_iter() + .map(|c| (c.identifier, c.commitments)) + .collect(); + + let sig_shares_vec: Vec<(Vec, Vec)> = sig_shares + .into_iter() + .map(|s| (s.identifier, s.signature_share)) + .collect(); + + let output = aggregate_inner( + &message, + &commitments_vec, + &sig_shares_vec, + &public_key_package, + ) + .map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("aggregate error: {:?}", e), + ) + })?; + + serde_json::to_value(output).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("serialization error: {:?}", e), + ) + }) +} + +/// Verify a signature against a public key. +#[napi] +pub fn napi_verify_ed25519( + message: Vec, + signature: Vec, + public_key_package: Vec, +) -> Result { + verify_inner(&message, &signature, &public_key_package).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("verify error: {:?}", e), + ) + }) +} diff --git a/crypto/teddsa/teddsa_addon/addon/teddsa-addon.darwin-arm64.node b/crypto/teddsa/teddsa_addon/addon/teddsa-addon.darwin-arm64.node new file mode 100755 index 000000000..cccc45e58 Binary files /dev/null and b/crypto/teddsa/teddsa_addon/addon/teddsa-addon.darwin-arm64.node differ diff --git a/crypto/teddsa/teddsa_addon/package.json b/crypto/teddsa/teddsa_addon/package.json new file mode 100644 index 000000000..7c8107296 --- /dev/null +++ b/crypto/teddsa/teddsa_addon/package.json @@ -0,0 +1,26 @@ +{ + "name": "@oko-wallet/teddsa-addon", + "private": true, + "version": "0.0.1", + "type": "module", + "main": "./src/server/index.ts", + "workspaces": { + "packages": [ + "./addon" + ] + }, + "scripts": { + "build:addon": "cd addon && npm run build", + "test:sign": "npx tsx src/tests/ed25519_sign.test.ts", + "test:keygen": "npx tsx src/tests/keygen.test.ts", + "test": "npm run test:keygen && npm run test:sign" + }, + "license": "MIT", + "dependencies": { + "@oko-wallet/teddsa-interface": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "^5.8.3" + } +} diff --git a/crypto/teddsa/teddsa_addon/src/server/index.ts b/crypto/teddsa/teddsa_addon/src/server/index.ts new file mode 100644 index 000000000..668fe475e --- /dev/null +++ b/crypto/teddsa/teddsa_addon/src/server/index.ts @@ -0,0 +1,73 @@ +import type { + TeddsaCentralizedKeygenOutput, + TeddsaSignRound1Output, + TeddsaSignRound2Output, + TeddsaAggregateOutput, + TeddsaCommitmentEntry, + TeddsaSignatureShareEntry, +} from "@oko-wallet/teddsa-interface"; + +import { + napiKeygenCentralizedEd25519, + napiKeygenImportEd25519, + napiSignRound1Ed25519, + napiSignRound2Ed25519, + napiAggregateEd25519, + napiVerifyEd25519, +} from "../../addon/index.js"; + +export function runKeygenCentralizedEd25519(): TeddsaCentralizedKeygenOutput { + return napiKeygenCentralizedEd25519(); +} + +export function runKeygenImportEd25519( + secretKey: Uint8Array, +): TeddsaCentralizedKeygenOutput { + return napiKeygenImportEd25519(Array.from(secretKey)); +} + +export function runSignRound1Ed25519( + keyPackage: Uint8Array, +): TeddsaSignRound1Output { + return napiSignRound1Ed25519(Array.from(keyPackage)); +} + +export function runSignRound2Ed25519( + message: Uint8Array, + keyPackage: Uint8Array, + nonces: Uint8Array, + allCommitments: TeddsaCommitmentEntry[], +): TeddsaSignRound2Output { + return napiSignRound2Ed25519( + Array.from(message), + Array.from(keyPackage), + Array.from(nonces), + allCommitments, + ); +} + +export function runAggregateEd25519( + message: Uint8Array, + allCommitments: TeddsaCommitmentEntry[], + allSignatureShares: TeddsaSignatureShareEntry[], + publicKeyPackage: Uint8Array, +): TeddsaAggregateOutput { + return napiAggregateEd25519( + Array.from(message), + allCommitments, + allSignatureShares, + Array.from(publicKeyPackage), + ); +} + +export function runVerifyEd25519( + message: Uint8Array, + signature: Uint8Array, + publicKeyPackage: Uint8Array, +): boolean { + return napiVerifyEd25519( + Array.from(message), + Array.from(signature), + Array.from(publicKeyPackage), + ); +} diff --git a/crypto/teddsa/teddsa_addon/src/tests/ed25519_sign.test.ts b/crypto/teddsa/teddsa_addon/src/tests/ed25519_sign.test.ts new file mode 100644 index 000000000..4dcdf6058 --- /dev/null +++ b/crypto/teddsa/teddsa_addon/src/tests/ed25519_sign.test.ts @@ -0,0 +1,176 @@ +import { Participant } from "@oko-wallet/teddsa-interface"; +import type { + TeddsaCentralizedKeygenOutput, + TeddsaSignRound1Output, + TeddsaCommitmentEntry, + TeddsaSignatureShareEntry, +} from "@oko-wallet/teddsa-interface"; + +import { + runKeygenCentralizedEd25519, + runSignRound1Ed25519, + runSignRound2Ed25519, + runAggregateEd25519, + runVerifyEd25519, +} from "../server"; + +interface TeddsaClientState { + keygenOutput?: TeddsaCentralizedKeygenOutput; + round1Output?: TeddsaSignRound1Output; +} + +interface TeddsaServerState { + keygenOutput?: TeddsaCentralizedKeygenOutput; + round1Output?: TeddsaSignRound1Output; +} + +function makeClientState(): TeddsaClientState { + return {}; +} + +function makeServerState(): TeddsaServerState { + return {}; +} + +export async function signTestEd25519( + clientState: TeddsaClientState, + serverState: TeddsaServerState, +) { + // 1. Centralized keygen - generates key shares for both parties + const keygenOutput = runKeygenCentralizedEd25519(); + const clientKeygenOutput = keygenOutput.keygen_outputs[Participant.P0]; + const serverKeygenOutput = keygenOutput.keygen_outputs[Participant.P1]; + + console.log( + `Keygen success. Public key length: ${keygenOutput.public_key.length} bytes`, + ); + + // Store key packages + clientState.keygenOutput = keygenOutput; + serverState.keygenOutput = keygenOutput; + + // Test message to sign + const message = new TextEncoder().encode("Hello, Solana!"); + + // 2. Round 1 - Both parties generate nonces and commitments + const clientRound1 = runSignRound1Ed25519( + new Uint8Array(clientKeygenOutput.key_package), + ); + const serverRound1 = runSignRound1Ed25519( + new Uint8Array(serverKeygenOutput.key_package), + ); + + console.log("Round 1 complete - nonces and commitments generated"); + + // Collect all commitments + const allCommitments: TeddsaCommitmentEntry[] = [ + { + identifier: clientRound1.identifier, + commitments: clientRound1.commitments, + }, + { + identifier: serverRound1.identifier, + commitments: serverRound1.commitments, + }, + ]; + + // 3. Round 2 - Both parties generate signature shares + const clientRound2 = runSignRound2Ed25519( + message, + new Uint8Array(clientKeygenOutput.key_package), + new Uint8Array(clientRound1.nonces), + allCommitments, + ); + const serverRound2 = runSignRound2Ed25519( + message, + new Uint8Array(serverKeygenOutput.key_package), + new Uint8Array(serverRound1.nonces), + allCommitments, + ); + + console.log("Round 2 complete - signature shares generated"); + + // Collect all signature shares + const allSignatureShares: TeddsaSignatureShareEntry[] = [ + { + identifier: clientRound2.identifier, + signature_share: clientRound2.signature_share, + }, + { + identifier: serverRound2.identifier, + signature_share: serverRound2.signature_share, + }, + ]; + + // 4. Aggregate - Combine signature shares into final signature + const aggregateOutput = runAggregateEd25519( + message, + allCommitments, + allSignatureShares, + new Uint8Array(clientKeygenOutput.public_key_package), + ); + + console.log( + `Aggregate success. Signature length: ${aggregateOutput.signature.length} bytes`, + ); + + if (aggregateOutput.signature.length !== 64) { + throw new Error( + `Invalid signature length: expected 64, got ${aggregateOutput.signature.length}`, + ); + } + + // 5. Verify - Check that the signature is valid + const isValid = runVerifyEd25519( + message, + new Uint8Array(aggregateOutput.signature), + new Uint8Array(clientKeygenOutput.public_key_package), + ); + + if (isValid) { + console.log("Verification SUCCESS - signature is valid"); + } else { + throw new Error("Verification FAILED - signature is invalid"); + } + + // Verify with wrong message should fail + const wrongMessage = new TextEncoder().encode("Wrong message!"); + const isValidWrong = runVerifyEd25519( + wrongMessage, + new Uint8Array(aggregateOutput.signature), + new Uint8Array(clientKeygenOutput.public_key_package), + ); + + if (!isValidWrong) { + console.log("Negative test passed - wrong message correctly rejected"); + } else { + throw new Error( + "Negative test FAILED - wrong message was incorrectly accepted", + ); + } + + return { + signature: aggregateOutput.signature, + publicKey: keygenOutput.public_key, + }; +} + +// Run the test +async function main() { + console.log("Starting Ed25519 threshold signature test...\n"); + + const clientState = makeClientState(); + const serverState = makeServerState(); + + try { + const result = await signTestEd25519(clientState, serverState); + console.log("\n=== Test Complete ==="); + console.log(`Signature (hex): ${Buffer.from(result.signature).toString("hex")}`); + console.log(`Public Key (hex): ${Buffer.from(result.publicKey).toString("hex")}`); + } catch (error) { + console.error("Test failed:", error); + process.exit(1); + } +} + +main(); diff --git a/crypto/teddsa/teddsa_addon/src/tests/keygen.test.ts b/crypto/teddsa/teddsa_addon/src/tests/keygen.test.ts new file mode 100644 index 000000000..bdc322b64 --- /dev/null +++ b/crypto/teddsa/teddsa_addon/src/tests/keygen.test.ts @@ -0,0 +1,137 @@ +import { Participant } from "@oko-wallet/teddsa-interface"; + +import { + runKeygenCentralizedEd25519, + runKeygenImportEd25519, +} from "../server"; + +export async function keygenCentralizedTest() { + console.log("Testing centralized keygen...\n"); + + // Generate new key + const keygenOutput = runKeygenCentralizedEd25519(); + + // Validate output structure + if (!keygenOutput.keygen_outputs || keygenOutput.keygen_outputs.length !== 2) { + throw new Error("Expected 2 keygen outputs for 2-of-2 threshold"); + } + + if (!keygenOutput.public_key || keygenOutput.public_key.length !== 32) { + throw new Error("Expected 32-byte Ed25519 public key"); + } + + if (!keygenOutput.private_key || keygenOutput.private_key.length !== 32) { + throw new Error("Expected 32-byte Ed25519 private key"); + } + + const clientOutput = keygenOutput.keygen_outputs[Participant.P0]; + const serverOutput = keygenOutput.keygen_outputs[Participant.P1]; + + // Validate client output + if (!clientOutput.key_package || clientOutput.key_package.length === 0) { + throw new Error("Client key_package is empty"); + } + if (!clientOutput.public_key_package || clientOutput.public_key_package.length === 0) { + throw new Error("Client public_key_package is empty"); + } + if (!clientOutput.identifier || clientOutput.identifier.length === 0) { + throw new Error("Client identifier is empty"); + } + + // Validate server output + if (!serverOutput.key_package || serverOutput.key_package.length === 0) { + throw new Error("Server key_package is empty"); + } + if (!serverOutput.public_key_package || serverOutput.public_key_package.length === 0) { + throw new Error("Server public_key_package is empty"); + } + if (!serverOutput.identifier || serverOutput.identifier.length === 0) { + throw new Error("Server identifier is empty"); + } + + // Public key packages should be the same for both parties + const clientPkgHex = Buffer.from(clientOutput.public_key_package).toString("hex"); + const serverPkgHex = Buffer.from(serverOutput.public_key_package).toString("hex"); + if (clientPkgHex !== serverPkgHex) { + throw new Error("Public key packages should match between parties"); + } + + // Identifiers should be different + const clientIdHex = Buffer.from(clientOutput.identifier).toString("hex"); + const serverIdHex = Buffer.from(serverOutput.identifier).toString("hex"); + if (clientIdHex === serverIdHex) { + throw new Error("Participant identifiers should be different"); + } + + console.log("Centralized keygen validation passed!"); + console.log(` Public key: ${Buffer.from(keygenOutput.public_key).toString("hex")}`); + console.log(` Client identifier: ${clientIdHex}`); + console.log(` Server identifier: ${serverIdHex}`); + console.log(` Key package size: ${clientOutput.key_package.length} bytes`); + console.log(` Public key package size: ${clientOutput.public_key_package.length} bytes`); + + return keygenOutput; +} + +export async function keygenImportTest() { + console.log("\nTesting import keygen...\n"); + + // Generate a test secret key (32 bytes) + const secretKey = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + secretKey[i] = i; + } + + // Import the key + const keygenOutput = runKeygenImportEd25519(secretKey); + + // Validate output structure + if (!keygenOutput.keygen_outputs || keygenOutput.keygen_outputs.length !== 2) { + throw new Error("Expected 2 keygen outputs for 2-of-2 threshold"); + } + + if (!keygenOutput.public_key || keygenOutput.public_key.length !== 32) { + throw new Error("Expected 32-byte Ed25519 public key"); + } + + // The returned private key should match the input + const returnedPrivateKey = Buffer.from(keygenOutput.private_key).toString("hex"); + const inputPrivateKey = Buffer.from(secretKey).toString("hex"); + if (returnedPrivateKey !== inputPrivateKey) { + throw new Error("Returned private key should match input"); + } + + // Import same key again - should get same public key + const keygenOutput2 = runKeygenImportEd25519(secretKey); + const pubKey1 = Buffer.from(keygenOutput.public_key).toString("hex"); + const pubKey2 = Buffer.from(keygenOutput2.public_key).toString("hex"); + if (pubKey1 !== pubKey2) { + throw new Error("Same secret key should produce same public key"); + } + + console.log("Import keygen validation passed!"); + console.log(` Public key: ${pubKey1}`); + console.log(` Private key matches input: yes`); + console.log(` Deterministic: yes`); + + return keygenOutput; +} + +// Run the tests +async function main() { + console.log("Starting Ed25519 keygen tests...\n"); + console.log("=".repeat(50)); + + try { + await keygenCentralizedTest(); + await keygenImportTest(); + + console.log("\n" + "=".repeat(50)); + console.log("All keygen tests passed!"); + } catch (error) { + console.error("\nTest failed:", error); + process.exit(1); + } +} + +main(); diff --git a/crypto/teddsa/teddsa_addon/tsconfig.json b/crypto/teddsa/teddsa_addon/tsconfig.json new file mode 100644 index 000000000..e944089c6 --- /dev/null +++ b/crypto/teddsa/teddsa_addon/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "addon"] +} diff --git a/crypto/teddsa/teddsa_hooks/package.json b/crypto/teddsa/teddsa_hooks/package.json new file mode 100644 index 000000000..6da1116df --- /dev/null +++ b/crypto/teddsa/teddsa_hooks/package.json @@ -0,0 +1,15 @@ +{ + "name": "@oko-wallet/teddsa-hooks", + "main": "./src/index.ts", + "version": "0.1.0", + "dependencies": { + "@oko-wallet/bytes": "^0.0.3-alpha.62", + "@oko-wallet/stdlib-js": "^0.0.2-rc.42", + "@oko-wallet/teddsa-interface": "workspace:*", + "@oko-wallet/teddsa-wasm-mock": "workspace:*" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "^5.8.3" + } +} \ No newline at end of file diff --git a/crypto/teddsa/teddsa_hooks/src/index.ts b/crypto/teddsa/teddsa_hooks/src/index.ts new file mode 100644 index 000000000..1e7bae806 --- /dev/null +++ b/crypto/teddsa/teddsa_hooks/src/index.ts @@ -0,0 +1,4 @@ +export * from "./keygen"; +export * from "./sign"; +export * from "./types"; +export * from "./participant"; diff --git a/crypto/teddsa/teddsa_hooks/src/keygen.ts b/crypto/teddsa/teddsa_hooks/src/keygen.ts new file mode 100644 index 000000000..508c9b788 --- /dev/null +++ b/crypto/teddsa/teddsa_hooks/src/keygen.ts @@ -0,0 +1,75 @@ +import { wasmModule } from "@oko-wallet/teddsa-wasm"; +import { Bytes } from "@oko-wallet/bytes"; +import type { Bytes32 } from "@oko-wallet/bytes"; +import type { Result } from "@oko-wallet/stdlib-js"; +import type { TeddsaCentralizedKeygenOutput } from "@oko-wallet/teddsa-interface"; + +import type { TeddsaKeygenResult, TeddsaKeygenOutputBytes } from "./types"; + +export async function importExternalSecretKeyEd25519( + secretKey: Bytes32, +): Promise> { + try { + const keygenOutput: TeddsaCentralizedKeygenOutput = + wasmModule.cli_keygen_import_ed25519([...secretKey.toUint8Array()]); + + return processKeygenOutput(keygenOutput); + } catch (error: any) { + return { + success: false, + err: String(error), + }; + } +} + +export async function runTeddsaKeygen(): Promise< + Result +> { + try { + const keygenOutput: TeddsaCentralizedKeygenOutput = + wasmModule.cli_keygen_centralized_ed25519(); + + return processKeygenOutput(keygenOutput); + } catch (error: any) { + return { + success: false, + err: String(error), + }; + } +} + +function processKeygenOutput( + keygenOutput: TeddsaCentralizedKeygenOutput, +): Result { + const [keygen_1_raw, keygen_2_raw] = keygenOutput.keygen_outputs; + + const publicKeyBytesRes = Bytes.fromUint8Array( + new Uint8Array(keygenOutput.public_key), + 32, + ); + if (publicKeyBytesRes.success === false) { + return { + success: false, + err: publicKeyBytesRes.err, + }; + } + + const keygen_1: TeddsaKeygenOutputBytes = { + key_package: new Uint8Array(keygen_1_raw.key_package), + public_key_package: new Uint8Array(keygen_1_raw.public_key_package), + identifier: new Uint8Array(keygen_1_raw.identifier), + public_key: publicKeyBytesRes.data, + }; + + const keygen_2: TeddsaKeygenOutputBytes = { + key_package: new Uint8Array(keygen_2_raw.key_package), + public_key_package: new Uint8Array(keygen_2_raw.public_key_package), + identifier: new Uint8Array(keygen_2_raw.identifier), + public_key: publicKeyBytesRes.data, + }; + + return { + success: true, + data: { keygen_1, keygen_2 }, + }; +} diff --git a/crypto/teddsa/teddsa_hooks/src/participant.ts b/crypto/teddsa/teddsa_hooks/src/participant.ts new file mode 100644 index 000000000..7d565fef5 --- /dev/null +++ b/crypto/teddsa/teddsa_hooks/src/participant.ts @@ -0,0 +1,58 @@ +/** + * Participant identifiers for 2-of-2 TEdDSA threshold scheme. + * + * In FROST 2-of-2 with IdentifierList::Default: + * - P0 (Client) = identifier 1 + * - P1 (Server) = identifier 2 + */ +export enum Participant { + /** Client participant (keygen_1, identifier = 1) */ + P0 = 0, + /** Server participant (keygen_2, identifier = 2) */ + P1 = 1, +} + +/** + * Convert Participant enum to 32-byte FROST identifier. + * + * FROST identifiers are 32-byte scalars where: + * - P0 (Client): [1, 0, 0, ..., 0] (identifier 1) + * - P1 (Server): [2, 0, 0, ..., 0] (identifier 2) + */ +export function participantToIdentifier(participant: Participant): number[] { + const identifier = new Array(32).fill(0); + identifier[0] = participant === Participant.P0 ? 1 : 2; + return identifier; +} + +/** + * Convert Participant enum to 32-byte FROST identifier as Uint8Array. + */ +export function participantToIdentifierBytes( + participant: Participant, +): Uint8Array { + return new Uint8Array(participantToIdentifier(participant)); +} + +/** + * Check if an identifier matches a participant. + */ +export function identifierMatchesParticipant( + identifier: number[] | Uint8Array, + participant: Participant, +): boolean { + const expectedFirstByte = participant === Participant.P0 ? 1 : 2; + return identifier[0] === expectedFirstByte; +} + +/** + * Get Participant from identifier bytes. + * Returns null if identifier doesn't match expected pattern. + */ +export function participantFromIdentifier( + identifier: number[] | Uint8Array, +): Participant | null { + if (identifier[0] === 1) return Participant.P0; + if (identifier[0] === 2) return Participant.P1; + return null; +} diff --git a/crypto/teddsa/teddsa_hooks/src/sign.ts b/crypto/teddsa/teddsa_hooks/src/sign.ts new file mode 100644 index 000000000..737c8ecbc --- /dev/null +++ b/crypto/teddsa/teddsa_hooks/src/sign.ts @@ -0,0 +1,166 @@ +import { wasmModule } from "@oko-wallet/teddsa-wasm"; +import type { Result } from "@oko-wallet/stdlib-js"; +import type { + TeddsaSignRound1Output, + TeddsaSignRound2Output, + TeddsaAggregateOutput, + TeddsaCommitmentEntry, + TeddsaSignatureShareEntry, +} from "@oko-wallet/teddsa-interface"; + +import type { TeddsaKeygenOutputBytes } from "./types"; + +export type TeddsaSignError = + | { type: "aborted" } + | { type: "error"; msg: string }; + +export function teddsaSignRound1( + keyPackage: Uint8Array, +): Result { + try { + const result: TeddsaSignRound1Output = wasmModule.cli_sign_round1_ed25519([ + ...keyPackage, + ]); + return { success: true, data: result }; + } catch (error: any) { + return { success: false, err: String(error) }; + } +} + +export function teddsaSignRound2( + message: Uint8Array, + keyPackage: Uint8Array, + nonces: Uint8Array, + allCommitments: TeddsaCommitmentEntry[], +): Result { + try { + const input = { + message: [...message], + key_package: [...keyPackage], + nonces: [...nonces], + all_commitments: allCommitments, + }; + const result: TeddsaSignRound2Output = + wasmModule.cli_sign_round2_ed25519(input); + return { success: true, data: result }; + } catch (error: any) { + return { success: false, err: String(error) }; + } +} + +export function teddsaAggregate( + message: Uint8Array, + allCommitments: TeddsaCommitmentEntry[], + allSignatureShares: TeddsaSignatureShareEntry[], + publicKeyPackage: Uint8Array, +): Result { + try { + const input = { + message: [...message], + all_commitments: allCommitments, + all_signature_shares: allSignatureShares, + public_key_package: [...publicKeyPackage], + }; + const result: TeddsaAggregateOutput = + wasmModule.cli_aggregate_ed25519(input); + return { success: true, data: new Uint8Array(result.signature) }; + } catch (error: any) { + return { success: false, err: String(error) }; + } +} + +export function teddsaVerify( + message: Uint8Array, + signature: Uint8Array, + publicKeyPackage: Uint8Array, +): Result { + try { + const input = { + message: [...message], + signature: [...signature], + public_key_package: [...publicKeyPackage], + }; + const isValid: boolean = wasmModule.cli_verify_ed25519(input); + return { success: true, data: isValid }; + } catch (error: any) { + return { success: false, err: String(error) }; + } +} + +export async function runTeddsaSignLocal( + message: Uint8Array, + keygen1: TeddsaKeygenOutputBytes, + keygen2: TeddsaKeygenOutputBytes, +): Promise> { + try { + const round1_1 = teddsaSignRound1(keygen1.key_package); + if (!round1_1.success) { + return { success: false, err: { type: "error", msg: round1_1.err } }; + } + + const round1_2 = teddsaSignRound1(keygen2.key_package); + if (!round1_2.success) { + return { success: false, err: { type: "error", msg: round1_2.err } }; + } + + const allCommitments: TeddsaCommitmentEntry[] = [ + { + identifier: round1_1.data.identifier, + commitments: round1_1.data.commitments, + }, + { + identifier: round1_2.data.identifier, + commitments: round1_2.data.commitments, + }, + ]; + + const round2_1 = teddsaSignRound2( + message, + keygen1.key_package, + new Uint8Array(round1_1.data.nonces), + allCommitments, + ); + if (!round2_1.success) { + return { success: false, err: { type: "error", msg: round2_1.err } }; + } + + const round2_2 = teddsaSignRound2( + message, + keygen2.key_package, + new Uint8Array(round1_2.data.nonces), + allCommitments, + ); + if (!round2_2.success) { + return { success: false, err: { type: "error", msg: round2_2.err } }; + } + + const allSignatureShares: TeddsaSignatureShareEntry[] = [ + { + identifier: round2_1.data.identifier, + signature_share: round2_1.data.signature_share, + }, + { + identifier: round2_2.data.identifier, + signature_share: round2_2.data.signature_share, + }, + ]; + + const aggregateResult = teddsaAggregate( + message, + allCommitments, + allSignatureShares, + keygen1.public_key_package, + ); + + if (!aggregateResult.success) { + return { + success: false, + err: { type: "error", msg: aggregateResult.err }, + }; + } + + return { success: true, data: aggregateResult.data }; + } catch (error: any) { + return { success: false, err: { type: "error", msg: String(error) } }; + } +} diff --git a/crypto/teddsa/teddsa_hooks/src/types.ts b/crypto/teddsa/teddsa_hooks/src/types.ts new file mode 100644 index 000000000..61c4ae498 --- /dev/null +++ b/crypto/teddsa/teddsa_hooks/src/types.ts @@ -0,0 +1,13 @@ +import type { Bytes32 } from "@oko-wallet/bytes"; + +export interface TeddsaKeygenOutputBytes { + key_package: Uint8Array; + public_key_package: Uint8Array; + identifier: Uint8Array; + public_key: Bytes32; +} + +export interface TeddsaKeygenResult { + keygen_1: TeddsaKeygenOutputBytes; + keygen_2: TeddsaKeygenOutputBytes; +} diff --git a/crypto/teddsa/teddsa_hooks/tsconfig.json b/crypto/teddsa/teddsa_hooks/tsconfig.json new file mode 100644 index 000000000..8d81c3fe1 --- /dev/null +++ b/crypto/teddsa/teddsa_hooks/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/crypto/teddsa/teddsa_interface/package.json b/crypto/teddsa/teddsa_interface/package.json new file mode 100644 index 000000000..cc1fce944 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/package.json @@ -0,0 +1,20 @@ +{ + "name": "@oko-wallet/teddsa-interface", + "version": "0.0.1-alpha.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "del-cli dist", + "build": "yarn clean && tsc && tsc-alias" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "del-cli": "^6.0.0", + "tsc-alias": "^1.8.16", + "typescript": "^5.8.3" + } +} diff --git a/crypto/teddsa/teddsa_interface/src/api/index.ts b/crypto/teddsa/teddsa_interface/src/api/index.ts new file mode 100644 index 000000000..682a70d17 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/src/api/index.ts @@ -0,0 +1,2 @@ +export * from "./keygen"; +export * from "./sign"; diff --git a/crypto/teddsa/teddsa_interface/src/api/keygen.ts b/crypto/teddsa/teddsa_interface/src/api/keygen.ts new file mode 100644 index 000000000..101e554a9 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/src/api/keygen.ts @@ -0,0 +1,21 @@ +import type { TeddsaKeygenOutput } from "../keygen"; + +export interface TeddsaKeygenInitRequest { + user_id: string; +} + +export interface TeddsaKeygenInitResponse { + session_id: string; +} + +export interface TeddsaKeygenStoreRequest { + user_id: string; + session_id: string; + keygen_output: TeddsaKeygenOutput; + public_key: number[]; +} + +export interface TeddsaKeygenStoreResponse { + success: boolean; + public_key: number[]; +} diff --git a/crypto/teddsa/teddsa_interface/src/api/sign.ts b/crypto/teddsa/teddsa_interface/src/api/sign.ts new file mode 100644 index 000000000..645379ec4 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/src/api/sign.ts @@ -0,0 +1,34 @@ +import type { + TeddsaCommitmentEntry, + TeddsaSignatureShareEntry, +} from "../sign"; + +export interface TeddsaSignRound1Request { + session_id: string; + message: number[]; + client_commitment: TeddsaCommitmentEntry; +} + +export interface TeddsaSignRound1Response { + server_commitment: TeddsaCommitmentEntry; +} + +export interface TeddsaSignRound2Request { + session_id: string; + client_signature_share: TeddsaSignatureShareEntry; +} + +export interface TeddsaSignRound2Response { + server_signature_share: TeddsaSignatureShareEntry; +} + +export interface TeddsaAggregateRequest { + session_id: string; + message: number[]; + all_commitments: TeddsaCommitmentEntry[]; + all_signature_shares: TeddsaSignatureShareEntry[]; +} + +export interface TeddsaAggregateResponse { + signature: number[]; +} diff --git a/crypto/teddsa/teddsa_interface/src/errors.ts b/crypto/teddsa/teddsa_interface/src/errors.ts new file mode 100644 index 000000000..9f6f3fd92 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/src/errors.ts @@ -0,0 +1,22 @@ +export type TeddsaErrorCode = + | "WASM_NOT_INITIALIZED" + | "WASM_INIT_FAILED" + | "KEYGEN_FAILED" + | "SIGN_ROUND1_FAILED" + | "SIGN_ROUND2_FAILED" + | "AGGREGATE_FAILED" + | "VERIFY_FAILED" + | "INVALID_INPUT" + | "SERIALIZATION_ERROR"; + +export class TeddsaException extends Error { + readonly code: TeddsaErrorCode; + readonly cause?: unknown; + + constructor(code: TeddsaErrorCode, message: string, cause?: unknown) { + super(message); + this.name = "TeddsaException"; + this.code = code; + this.cause = cause; + } +} diff --git a/crypto/teddsa/teddsa_interface/src/index.ts b/crypto/teddsa/teddsa_interface/src/index.ts new file mode 100644 index 000000000..30db38b20 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/src/index.ts @@ -0,0 +1,5 @@ +export * from "./keygen"; +export * from "./sign"; +export * from "./participant"; +export * from "./errors"; +export * from "./api"; diff --git a/crypto/teddsa/teddsa_interface/src/keygen.ts b/crypto/teddsa/teddsa_interface/src/keygen.ts new file mode 100644 index 000000000..cb41542e0 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/src/keygen.ts @@ -0,0 +1,17 @@ +export interface TeddsaKeygenOutput { + key_package: number[]; + public_key_package: number[]; + identifier: number[]; +} + +export interface TeddsaCentralizedKeygenOutput { + private_key: number[]; + keygen_outputs: TeddsaKeygenOutput[]; + public_key: number[]; +} + +export interface TeddsaClientKeygenState { + keygen_1: TeddsaKeygenOutput | null; + keygen_2: TeddsaKeygenOutput | null; + public_key: number[] | null; +} diff --git a/crypto/teddsa/teddsa_interface/src/participant.ts b/crypto/teddsa/teddsa_interface/src/participant.ts new file mode 100644 index 000000000..5e1f3f350 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/src/participant.ts @@ -0,0 +1,26 @@ +/** + * Participant identifiers for 2-of-2 TEdDSA threshold scheme. + * + * In FROST 2-of-2 with IdentifierList::Default: + * - P0 (Client) = identifier 1 + * - P1 (Server) = identifier 2 + */ +export enum Participant { + /** Client participant (keygen_1, identifier = 1) */ + P0 = 0, + /** Server participant (keygen_2, identifier = 2) */ + P1 = 1, +} + +/** + * Convert Participant enum to 32-byte FROST identifier. + * + * FROST identifiers are 32-byte scalars where: + * - P0 (Client): [1, 0, 0, ..., 0] (identifier 1) + * - P1 (Server): [2, 0, 0, ..., 0] (identifier 2) + */ +export function participantToIdentifier(participant: Participant): number[] { + const identifier = new Array(32).fill(0); + identifier[0] = participant === Participant.P0 ? 1 : 2; + return identifier; +} diff --git a/crypto/teddsa/teddsa_interface/src/sign.ts b/crypto/teddsa/teddsa_interface/src/sign.ts new file mode 100644 index 000000000..19df1f240 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/src/sign.ts @@ -0,0 +1,36 @@ +export interface TeddsaSignRound1Output { + nonces: number[]; + commitments: number[]; + identifier: number[]; +} + +export interface TeddsaSignRound2Output { + signature_share: number[]; + identifier: number[]; +} + +export interface TeddsaCommitmentEntry { + identifier: number[]; + commitments: number[]; +} + +export interface TeddsaSignatureShareEntry { + identifier: number[]; + signature_share: number[]; +} + +export interface TeddsaAggregateOutput { + signature: number[]; +} + +export interface TeddsaSignature { + signature: string; +} + +export interface TeddsaClientSignState { + message: Uint8Array | null; + nonces: number[] | null; + commitments: number[] | null; + all_commitments: TeddsaCommitmentEntry[] | null; + all_signature_shares: TeddsaSignatureShareEntry[] | null; +} diff --git a/crypto/teddsa/teddsa_interface/tsconfig.json b/crypto/teddsa/teddsa_interface/tsconfig.json new file mode 100644 index 000000000..8d81c3fe1 --- /dev/null +++ b/crypto/teddsa/teddsa_interface/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/crypto/teddsa/teddsa_keplr_addon_mock/addon/Cargo.toml b/crypto/teddsa/teddsa_keplr_addon_mock/addon/Cargo.toml index 0f1e9e281..dbd66b1ff 100644 --- a/crypto/teddsa/teddsa_keplr_addon_mock/addon/Cargo.toml +++ b/crypto/teddsa/teddsa_keplr_addon_mock/addon/Cargo.toml @@ -1,6 +1,6 @@ [package] edition = "2021" -name = "teddsa_addon" +name = "teddsa_addon_mock" version = "0.0.1" [lib] diff --git a/crypto/teddsa/teddsa_keplr_addon_mock/addon/src/sign.rs b/crypto/teddsa/teddsa_keplr_addon_mock/addon/src/sign.rs index 401aeda1a..bc502eb46 100644 --- a/crypto/teddsa/teddsa_keplr_addon_mock/addon/src/sign.rs +++ b/crypto/teddsa/teddsa_keplr_addon_mock/addon/src/sign.rs @@ -57,12 +57,13 @@ pub fn napi_sign_round2_ed25519( nonces: Vec, all_commitments: serde_json::Value, ) -> Result { - let commitments: Vec = serde_json::from_value(all_commitments).map_err(|e| { - napi::Error::new( - napi::Status::GenericFailure, - format!("deserialization error (all_commitments): {:?}", e), - ) - })?; + let commitments: Vec = + serde_json::from_value(all_commitments).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("deserialization error (all_commitments): {:?}", e), + ) + })?; let commitments_vec: Vec<(Vec, Vec)> = commitments .into_iter() @@ -101,15 +102,16 @@ pub fn napi_aggregate_ed25519( all_signature_shares: serde_json::Value, public_key_package: Vec, ) -> Result { - let commitments: Vec = serde_json::from_value(all_commitments).map_err(|e| { - napi::Error::new( - napi::Status::GenericFailure, - format!("deserialization error (all_commitments): {:?}", e), - ) - })?; + let commitments: Vec = + serde_json::from_value(all_commitments).map_err(|e| { + napi::Error::new( + napi::Status::GenericFailure, + format!("deserialization error (all_commitments): {:?}", e), + ) + })?; - let sig_shares: Vec = - serde_json::from_value(all_signature_shares).map_err(|e| { + let sig_shares: Vec = serde_json::from_value(all_signature_shares) + .map_err(|e| { napi::Error::new( napi::Status::GenericFailure, format!("deserialization error (all_signature_shares): {:?}", e), diff --git a/crypto/teddsa/teddsa_wasm_mock/wasm/src/keygen.rs b/crypto/teddsa/teddsa_wasm_mock/wasm/src/keygen.rs index 45488562a..026bc57c0 100644 --- a/crypto/teddsa/teddsa_wasm_mock/wasm/src/keygen.rs +++ b/crypto/teddsa/teddsa_wasm_mock/wasm/src/keygen.rs @@ -1,5 +1,5 @@ -use teddsa_keplr_mock::{keygen_centralized, keygen_import}; use gloo_utils::format::JsValueSerdeExt; +use teddsa_keplr_mock::{keygen_centralized, keygen_import}; use wasm_bindgen::prelude::*; /// Generate a 2-of-2 threshold Ed25519 key using centralized key generation. diff --git a/crypto/teddsa/teddsa_wasm_mock/wasm/src/sign.rs b/crypto/teddsa/teddsa_wasm_mock/wasm/src/sign.rs index 873474792..0ba38e608 100644 --- a/crypto/teddsa/teddsa_wasm_mock/wasm/src/sign.rs +++ b/crypto/teddsa/teddsa_wasm_mock/wasm/src/sign.rs @@ -1,6 +1,6 @@ -use teddsa_keplr_mock::{aggregate, sign_round1, sign_round2, verify}; use gloo_utils::format::JsValueSerdeExt; use serde::{Deserialize, Serialize}; +use teddsa_keplr_mock::{aggregate, sign_round1, sign_round2, verify}; use wasm_bindgen::prelude::*; /// Input for sign round 2 @@ -83,8 +83,13 @@ pub fn cli_sign_round2_ed25519(input: JsValue) -> Result { .map(|c| (c.identifier, c.commitments)) .collect(); - let out = sign_round2(&input.message, &input.key_package, &input.nonces, &all_commitments) - .map_err(|err| JsValue::from_str(&err.to_string()))?; + let out = sign_round2( + &input.message, + &input.key_package, + &input.nonces, + &all_commitments, + ) + .map_err(|err| JsValue::from_str(&err.to_string()))?; JsValue::from_serde(&out).map_err(|err| JsValue::from_str(&err.to_string())) } diff --git a/key_share_node/ksn_interface/package.json b/key_share_node/ksn_interface/package.json index 15190a59b..9cd189c2b 100644 --- a/key_share_node/ksn_interface/package.json +++ b/key_share_node/ksn_interface/package.json @@ -9,6 +9,10 @@ "build": "yarn clean && tsc --build --force && tsc-alias" }, "exports": { + "./curve_type": { + "import": "./dist/curve_type.js", + "types": "./dist/curve_type.d.ts" + }, "./key_share": { "import": "./dist/key_share.js", "types": "./dist/key_share.d.ts" diff --git a/key_share_node/ksn_interface/src/curve_type.ts b/key_share_node/ksn_interface/src/curve_type.ts index 32b572f5b..0915cbb2c 100644 --- a/key_share_node/ksn_interface/src/curve_type.ts +++ b/key_share_node/ksn_interface/src/curve_type.ts @@ -1 +1 @@ -export type CurveType = "secp256k1"; +export type CurveType = "secp256k1" | "ed25519"; diff --git a/key_share_node/ksn_interface/src/key_share.ts b/key_share_node/ksn_interface/src/key_share.ts index 514ac3b9a..9cf683bf9 100644 --- a/key_share_node/ksn_interface/src/key_share.ts +++ b/key_share_node/ksn_interface/src/key_share.ts @@ -1,4 +1,4 @@ -import type { Bytes33, Bytes64 } from "@oko-wallet/bytes"; +import type { Bytes32, Bytes33, Bytes64 } from "@oko-wallet/bytes"; import type { AuthType } from "@oko-wallet/oko-types/auth"; import type { CurveType } from "./curve_type"; @@ -31,7 +31,7 @@ export interface RegisterKeyShareRequest { email: string; auth_type: AuthType; curve_type: CurveType; - public_key: Bytes33; + public_key: Bytes32 | Bytes33; share: Bytes64; } @@ -44,7 +44,8 @@ export type RegisterKeyShareBody = { export interface GetKeyShareRequest { email: string; auth_type: AuthType; - public_key: Bytes33; + curve_type: CurveType; + public_key: Bytes32 | Bytes33; } export interface GetKeyShareResponse { @@ -53,13 +54,15 @@ export interface GetKeyShareResponse { } export type GetKeyShareRequestBody = { + curve_type: CurveType; public_key: string; // hex string }; export interface CheckKeyShareRequest { email: string; auth_type: AuthType; - public_key: Bytes33; + curve_type: CurveType; + public_key: Bytes32 | Bytes33; } export interface CheckKeyShareResponse { @@ -69,6 +72,7 @@ export interface CheckKeyShareResponse { export interface CheckKeyShareRequestBody { email: string; auth_type?: AuthType; + curve_type: CurveType; public_key: string; // hex string } @@ -76,7 +80,7 @@ export interface ReshareKeyShareRequest { email: string; auth_type: AuthType; curve_type: CurveType; - public_key: Bytes33; + public_key: Bytes32 | Bytes33; share: Bytes64; } diff --git a/key_share_node/server/src/api/key_share/index.ts b/key_share_node/server/src/api/key_share/index.ts index 107f56460..412568936 100644 --- a/key_share_node/server/src/api/key_share/index.ts +++ b/key_share_node/server/src/api/key_share/index.ts @@ -32,7 +32,7 @@ export async function registerKeyShare( const { email, auth_type, curve_type, public_key, share } = registerKeyShareRequest; - if (curve_type !== "secp256k1") { + if (curve_type !== "secp256k1" && curve_type !== "ed25519") { return { success: false, code: "CURVE_TYPE_NOT_SUPPORTED", @@ -226,7 +226,7 @@ export async function reshareKeyShare( const { email, auth_type, curve_type, public_key, share } = reshareKeyShareRequest; - if (curve_type !== "secp256k1") { + if (curve_type !== "secp256k1" && curve_type !== "ed25519") { return { success: false, code: "CURVE_TYPE_NOT_SUPPORTED", diff --git a/key_share_node/server/src/openapi/schema/key_share.ts b/key_share_node/server/src/openapi/schema/key_share.ts index 006a5f663..d606232e4 100644 --- a/key_share_node/server/src/openapi/schema/key_share.ts +++ b/key_share_node/server/src/openapi/schema/key_share.ts @@ -7,7 +7,7 @@ const authTypeSchema = z .openapi({ example: "google" }); const curveTypeSchema = z - .enum(["secp256k1"]) + .enum(["secp256k1", "ed25519"]) .describe("The curve type for the key share"); const publicKeySchema = z @@ -39,6 +39,7 @@ export const GetKeyShareRequestBodySchema = registry.register( z .object({ auth_type: authTypeSchema, + curve_type: curveTypeSchema, public_key: publicKeySchema, }) .openapi("GetKeyShareRequestBody", { @@ -70,6 +71,7 @@ export const CheckKeyShareRequestBodySchema = registry.register( .describe("Email address") .openapi({ example: "test@example.com" }), auth_type: authTypeSchema.optional().default("google"), + curve_type: curveTypeSchema, public_key: publicKeySchema, }) .openapi("CheckKeyShareRequestBody", { diff --git a/key_share_node/server/src/routes/key_share/index.ts b/key_share_node/server/src/routes/key_share/index.ts index 92b5059c3..ccd104cb8 100644 --- a/key_share_node/server/src/routes/key_share/index.ts +++ b/key_share_node/server/src/routes/key_share/index.ts @@ -149,7 +149,11 @@ export function makeKeyshareRouter() { const state = req.app.locals; const body = req.body; - const publicKeyBytesRes = Bytes.fromHexString(body.public_key, 33); + const publicKeyLength = body.curve_type === "ed25519" ? 32 : 33; + const publicKeyBytesRes = Bytes.fromHexString( + body.public_key, + publicKeyLength, + ); if (publicKeyBytesRes.success === false) { return res.status(400).json({ success: false, @@ -290,7 +294,11 @@ export function makeKeyshareRouter() { const auth_type = oauthUser.type; const state = req.app.locals; - const publicKeyBytesRes = Bytes.fromHexString(req.body.public_key, 33); + const publicKeyLength = req.body.curve_type === "ed25519" ? 32 : 33; + const publicKeyBytesRes = Bytes.fromHexString( + req.body.public_key, + publicKeyLength, + ); if (publicKeyBytesRes.success === false) { return res.status(400).json({ success: false, @@ -304,6 +312,7 @@ export function makeKeyshareRouter() { { email: oauthUser.email, auth_type, + curve_type: req.body.curve_type, public_key: publicKeyBytesRes.data, }, state.encryptionSecret, @@ -386,7 +395,11 @@ export function makeKeyshareRouter() { // @NOTE: default to google if auth_type is not provided const auth_type = (body.auth_type ?? "google") as AuthType; - const publicKeyBytesRes = Bytes.fromHexString(body.public_key, 33); + const publicKeyLength = body.curve_type === "ed25519" ? 32 : 33; + const publicKeyBytesRes = Bytes.fromHexString( + body.public_key, + publicKeyLength, + ); if (publicKeyBytesRes.success === false) { return res.status(400).json({ success: false, @@ -398,6 +411,7 @@ export function makeKeyshareRouter() { const checkKeyShareRes = await checkKeyShare(req.app.locals.db, { email: body.email, auth_type, + curve_type: body.curve_type, public_key: publicKeyBytesRes.data, }); if (checkKeyShareRes.success === false) { @@ -546,7 +560,11 @@ export function makeKeyshareRouter() { const state = req.app.locals; const body = req.body; - const publicKeyBytesRes = Bytes.fromHexString(body.public_key, 33); + const publicKeyLength = body.curve_type === "ed25519" ? 32 : 33; + const publicKeyBytesRes = Bytes.fromHexString( + body.public_key, + publicKeyLength, + ); if (publicKeyBytesRes.success === false) { return res.status(400).json({ success: false, diff --git a/package.json b/package.json index 6ebea0633..f02b82b7f 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,10 @@ "crypto/teddsa/teddsa_wasm_mock", "crypto/teddsa/teddsa_hooks_mock", "crypto/teddsa/teddsa_keplr_addon_mock", + "crypto/teddsa/teddsa_interface", + "crypto/teddsa/teddsa_hooks", + "crypto/teddsa/teddsa_addon", + "crypto/teddsa/api_lib", "crypto/teddsa/frost_ed25519_keplr_wasm", "sandbox/sandbox_sol" ] diff --git a/rustfmt.toml b/rustfmt.toml index 3930259a2..02a3238b5 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,2 +1,3 @@ tab_spaces = 4 -edition = "2021" \ No newline at end of file +max_width = 100 +edition = "2021" diff --git a/yarn.lock b/yarn.lock index f3823a75b..410b1bae2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10963,6 +10963,7 @@ __metadata: "@oko-wallet/bytes": "npm:^0.0.3-alpha.64" "@oko-wallet/stdlib-js": "npm:0.0.2-rc.41" "@oko-wallet/tecdsa-interface": "npm:0.0.2-alpha.22" + "@oko-wallet/teddsa-interface": "workspace:*" "@types/jest": "npm:^29.5.14" "@types/node": "npm:^24.10.1" del-cli: "npm:^6.0.0" @@ -11160,6 +11161,35 @@ __metadata: languageName: unknown linkType: soft +"@oko-wallet/teddsa-addon-native@workspace:crypto/teddsa/teddsa_addon/addon": + version: 0.0.0-use.local + resolution: "@oko-wallet/teddsa-addon-native@workspace:crypto/teddsa/teddsa_addon/addon" + dependencies: + "@napi-rs/cli": "npm:^2.18.4" + ava: "npm:^6.0.1" + languageName: unknown + linkType: soft + +"@oko-wallet/teddsa-addon@workspace:*, @oko-wallet/teddsa-addon@workspace:crypto/teddsa/teddsa_addon": + version: 0.0.0-use.local + resolution: "@oko-wallet/teddsa-addon@workspace:crypto/teddsa/teddsa_addon" + dependencies: + "@oko-wallet/teddsa-interface": "workspace:*" + "@types/node": "npm:^24.10.1" + typescript: "npm:^5.8.3" + languageName: unknown + linkType: soft + +"@oko-wallet/teddsa-api-lib@workspace:crypto/teddsa/api_lib": + version: 0.0.0-use.local + resolution: "@oko-wallet/teddsa-api-lib@workspace:crypto/teddsa/api_lib" + dependencies: + "@oko-wallet/oko-types": "npm:^0.0.1-alpha.4" + "@types/node": "npm:^24.10.1" + typescript: "npm:^5.8.3" + languageName: unknown + linkType: soft + "@oko-wallet/teddsa-hooks-mock@workspace:crypto/teddsa/teddsa_hooks_mock": version: 0.0.0-use.local resolution: "@oko-wallet/teddsa-hooks-mock@workspace:crypto/teddsa/teddsa_hooks_mock" @@ -11172,6 +11202,19 @@ __metadata: languageName: unknown linkType: soft +"@oko-wallet/teddsa-hooks@workspace:crypto/teddsa/teddsa_hooks": + version: 0.0.0-use.local + resolution: "@oko-wallet/teddsa-hooks@workspace:crypto/teddsa/teddsa_hooks" + dependencies: + "@oko-wallet/bytes": "npm:^0.0.3-alpha.62" + "@oko-wallet/stdlib-js": "npm:^0.0.2-rc.42" + "@oko-wallet/teddsa-interface": "workspace:*" + "@oko-wallet/teddsa-wasm-mock": "workspace:*" + "@types/node": "npm:^24.10.1" + typescript: "npm:^5.8.3" + languageName: unknown + linkType: soft + "@oko-wallet/teddsa-interface-mock@workspace:*, @oko-wallet/teddsa-interface-mock@workspace:crypto/teddsa/teddsa_interface_mock": version: 0.0.0-use.local resolution: "@oko-wallet/teddsa-interface-mock@workspace:crypto/teddsa/teddsa_interface_mock" @@ -11183,6 +11226,17 @@ __metadata: languageName: unknown linkType: soft +"@oko-wallet/teddsa-interface@workspace:*, @oko-wallet/teddsa-interface@workspace:crypto/teddsa/teddsa_interface": + version: 0.0.0-use.local + resolution: "@oko-wallet/teddsa-interface@workspace:crypto/teddsa/teddsa_interface" + dependencies: + "@types/node": "npm:^24.10.1" + del-cli: "npm:^6.0.0" + tsc-alias: "npm:^1.8.16" + typescript: "npm:^5.8.3" + languageName: unknown + linkType: soft + "@oko-wallet/teddsa-keplr-addon-mock@workspace:crypto/teddsa/teddsa_keplr_addon_mock": version: 0.0.0-use.local resolution: "@oko-wallet/teddsa-keplr-addon-mock@workspace:crypto/teddsa/teddsa_keplr_addon_mock" @@ -11220,6 +11274,8 @@ __metadata: "@oko-wallet/social-login-api": "workspace:*" "@oko-wallet/stdlib-js": "npm:^0.0.2-rc.44" "@oko-wallet/tecdsa-interface": "npm:0.0.2-alpha.22" + "@oko-wallet/teddsa-addon": "workspace:*" + "@oko-wallet/teddsa-interface": "workspace:*" "@types/cors": "npm:^2.8.17" "@types/express": "npm:^5.0.6" "@types/jest": "npm:^29.5.14"