diff --git a/packages/signers/package.json b/packages/signers/package.json index bde1a32..41fb226 100644 --- a/packages/signers/package.json +++ b/packages/signers/package.json @@ -39,6 +39,8 @@ "@noble/hashes": "^1.5.0", "@noble/secp256k1": "^2.3.0", "@sovereign-sdk/utils": "workspace:^", + "@turnkey/api-key-stamper": "^0.4.7", + "@turnkey/http": "^3.5.1", "ethers": "^6.15.0" } } diff --git a/packages/signers/src/turnkey-signer.ts b/packages/signers/src/turnkey-signer.ts new file mode 100644 index 0000000..71cbae0 --- /dev/null +++ b/packages/signers/src/turnkey-signer.ts @@ -0,0 +1,131 @@ +import { TurnkeyClient } from "@turnkey/http"; +import { ApiKeyStamper } from "@turnkey/api-key-stamper"; +import { ethers } from "ethers"; // v6 helpers +import type { Signer } from "@sovereign-sdk/signers"; +import { hexToBytes, bytesToHex } from "@sovereign-sdk/utils"; +import { Point } from "@noble/secp256k1"; +import { keccak_256 } from "@noble/hashes/sha3"; + +export interface TurnkeyConfig { + organizationId: string; + apiPublicKey: string; + apiPrivateKey: string; + keyId: string; +} + +// Helper function to wait for a certain amount of time. +const wait = (ms: number) => new Promise(r => setTimeout(r, ms)); + +// A signer key from Turnkey. +export class TurnkeySigner implements Signer { + private tk: TurnkeyClient; + private config: TurnkeyConfig; + public readonly _publicKey: Uint8Array; + public readonly curve: string; + constructor( + publicKey: Uint8Array, + turnkeyClient: TurnkeyClient, + config: TurnkeyConfig, + curve: string + ) { + this._publicKey = publicKey; + this.tk = turnkeyClient; + this.config = config; + this.curve = curve; + } + + public static async create(config: TurnkeyConfig): Promise { + + const stamper = new ApiKeyStamper({ + apiPublicKey: config.apiPublicKey, + apiPrivateKey: config.apiPrivateKey, + }); + + const turnkeyClient = new TurnkeyClient({ baseUrl: "https://api.turnkey.com" }, stamper); + + const response = await turnkeyClient.getPrivateKey({ + organizationId: config.organizationId, + privateKeyId: config.keyId, + }); + + if (response.privateKey.curve === "CURVE_SECP256K1") { + const uncompressedPublicKey = response.privateKey.publicKey; + if (!uncompressedPublicKey) { + throw new Error(`Could not retrieve public key for Turnkey key ${config.keyId}`); + } + // The public key from Turnkey is uncompressed (prefixed with 0x04, 65 bytes). + // The Sovereign SDK expects a compressed (33-byte) public key. + const point = Point.fromHex(uncompressedPublicKey); + const compressedPublicKey = point.toRawBytes(true); + return new TurnkeySigner(compressedPublicKey, turnkeyClient, config, "secp256k1"); + } + + if (response.privateKey.curve === "CURVE_ED25519") { + const publicKeyHex = response.privateKey.publicKey; + if (!publicKeyHex) { + throw new Error(`Could not retrieve public key for Turnkey key ${config.keyId}`); + } + // ED25519 public keys are 32 bytes in raw format + // Convert from hex string to Uint8Array + const publicKeyBytes = hexToBytes(publicKeyHex); + return new TurnkeySigner(publicKeyBytes, turnkeyClient, config, "ed25519"); + } + + throw new Error(`Unsupported curve: ${response.privateKey.curve}`); + + + } + + public async publicKey(): Promise { + return this._publicKey; + } + + async sign(message: Uint8Array): Promise { + // For ed255519, the hashing is internal to turnkey. Their docs state that only `HASH_FUNCTION_NOT_APPLICABLE` is supported. + const payload = this.curve === "secp256k1" ? bytesToHex(keccak_256(message)) : bytesToHex(message); + + const response = await this.tk.signRawPayload({ + type: "ACTIVITY_TYPE_SIGN_RAW_PAYLOAD_V2", + organizationId: this.config.organizationId, + timestampMs: String(Date.now()), + parameters: { + signWith: this.config.keyId, + payload, + encoding: "PAYLOAD_ENCODING_HEXADECIMAL", + hashFunction: this.curve === "secp256k1" ? "HASH_FUNCTION_NO_OP" : "HASH_FUNCTION_NOT_APPLICABLE", + }, + }); + await wait(1000); + + // Poll every 60s until COMPLETED + while (true) { + const { activity } = await this.tk.getActivity({ + organizationId: this.config.organizationId, + activityId: response.activity.id, + }); + + if (activity.status === "ACTIVITY_STATUS_COMPLETED") { + if (activity.result?.signRawPayloadResult) { + const { r, s } = activity.result.signRawPayloadResult; + + // The Sovereign SDK expects a 64-byte signature (r || s). + // The recovery ID (v) is handled separately by the SDK. + const sigBytes = ethers.concat([ + ethers.getBytes(`0x${r}`), + ethers.getBytes(`0x${s}`), + ]); + + return hexToBytes(sigBytes); + } + + throw new Error(`Turnkey activity completed but missing signature result. Full activity: ${JSON.stringify(activity, null, 2)}`); + } + + if (activity.status === "ACTIVITY_STATUS_REJECTED" || activity.status === "ACTIVITY_STATUS_FAILED") { + throw new Error(`Turnkey activity was not completed. Status: ${activity.status}`); + } + await wait(60000); + + } + } +} diff --git a/packages/signers/src/turnkey.test.ts b/packages/signers/src/turnkey.test.ts new file mode 100644 index 0000000..1f2e9ff --- /dev/null +++ b/packages/signers/src/turnkey.test.ts @@ -0,0 +1,164 @@ + +import * as ed25519 from "@noble/ed25519"; +import { describe, expect, it } from "vitest"; +import { TurnkeySigner, TurnkeyConfig } from "./turnkey-signer"; +import { keccak_256 } from "@noble/hashes/sha3"; +import * as secp256k1 from "@noble/secp256k1"; +import { Ed25519Signer } from "./ed25519"; +import { TurnkeyClient } from "@turnkey/http"; +import { Secp256k1Signer } from "./secp256k1"; +import { Point } from "@noble/secp256k1"; + +// Mock TurnkeyClient for testing +const createMockTurnkeyClient = (curve: "CURVE_ED25519" | "CURVE_SECP256K1", testSigner: any) => { + let lastParameters: any = null; + + return { + getPrivateKey: async () => { + const publicKeyHex = curve === "CURVE_ED25519" + ? Buffer.from(await testSigner.publicKey()).toString('hex') + : Point.fromPrivateKey(testSigner.privateKeyBytes).toHex(false); // uncompressed for secp256k1 + + return { + privateKey: { + curve, + publicKey: publicKeyHex + } + }; + }, + signRawPayload: async ({ parameters }: any) => { + lastParameters = parameters; + return { + activity: { + id: "mock-activity-id" + } + }; + }, + getActivity: async () => { + if (!lastParameters) { + throw new Error("No parameters found from signRawPayload"); + } + + const message = lastParameters.encoding === "PAYLOAD_ENCODING_HEXADECIMAL" + ? Buffer.from(lastParameters.payload, 'hex') + : new Uint8Array(); + + let signature: Uint8Array; + if (curve === "CURVE_ED25519") { + // For ed25519, TurnkeySigner sends the raw message + signature = await testSigner.sign(message); + } else { + // For secp256k1, TurnkeySigner already hashed the message before sending + // We need to sign the hash directly without re-hashing + const sig = await secp256k1.signAsync(message, testSigner.privateKeyBytes); + signature = sig.toCompactRawBytes(); + } + + if (curve === "CURVE_ED25519") { + return { + activity: { + status: "ACTIVITY_STATUS_COMPLETED", + result: { + signRawPayloadResult: { + r: Buffer.from(signature.slice(0, 32)).toString('hex'), + s: Buffer.from(signature.slice(32, 64)).toString('hex') + } + } + } + }; + } // secp256k1 signature parsing + const sig = secp256k1.Signature.fromCompact(signature); + return { + activity: { + status: "ACTIVITY_STATUS_COMPLETED", + result: { + signRawPayloadResult: { + r: sig.r.toString(16).padStart(64, '0'), + s: sig.s.toString(16).padStart(64, '0') + } + } + } + }; + } + } as any; +}; + +describe("TurnkeySigner - Ed25519", async () => { + const testPrivateKeyBytes = new Uint8Array(32).fill(1); + const testMessage = new Uint8Array([4, 5, 6]); + const vanillaSigner = new Ed25519Signer(testPrivateKeyBytes); + const publickey = await vanillaSigner.publicKey(); + + const ORG_ID = 'mock-org'; + const mockClient = createMockTurnkeyClient("CURVE_ED25519", vanillaSigner); + const signer = new TurnkeySigner(publickey, mockClient, { + organizationId: ORG_ID, + apiPublicKey: "foo", + apiPrivateKey: "bar", + keyId: "baz", + }, "ed25519"); + + it("should sign a message and verify the signature", async () => { + + const signature = await signer.sign(testMessage); + const publicKey = await signer.publicKey(); + const isValid = await ed25519.verifyAsync( + signature, + testMessage, + publicKey, + ); + expect(isValid).toBe(true); + }, 100000); + it("should fail verification with tampered message", async () => { + const signature = await signer.sign(testMessage); + const publicKey = await signer.publicKey(); + const tamperedMessage = new Uint8Array([...testMessage, 7]); // Tamper by adding extra byte + const isValid = await ed25519.verifyAsync( + signature, + tamperedMessage, + publicKey, + ); + expect(isValid).toBe(false); + }); +}) + + +describe("TurnkeySigner - Secp256k1", () => { + const testPrivateKeyBytes = new Uint8Array(32).fill(2); + const testMessage = new Uint8Array([4, 5, 6]); + const vanillaSigner = new Secp256k1Signer(testPrivateKeyBytes); + // Add private key bytes to the signer for the mock to access + (vanillaSigner as any).privateKeyBytes = testPrivateKeyBytes; + + const ORG_ID = 'mock-org'; + const mockClient = createMockTurnkeyClient("CURVE_SECP256K1", vanillaSigner); + + // Get compressed public key for TurnkeySigner (matching the behavior in create method) + const point = Point.fromPrivateKey(testPrivateKeyBytes); + const compressedPublicKey = point.toRawBytes(true); + const signer = new TurnkeySigner(compressedPublicKey, mockClient, { + organizationId: ORG_ID, + apiPublicKey: "foo", + apiPrivateKey: "bar", + keyId: "baz", + }, "secp256k1"); + + it("should sign a message and verify the signature", async () => { + const signature = await signer.sign(testMessage); + const publicKey = await signer.publicKey(); + const msgHash = keccak_256(testMessage); + const sig = secp256k1.Signature.fromCompact(signature); + const isValid = secp256k1.verify(sig, msgHash, publicKey); + expect(isValid).toBe(true); + }, 100000); + + it("should fail verification with tampered message", async () => { + const signature = await signer.sign(testMessage); + const publicKey = await signer.publicKey(); + const tamperedMessage = new Uint8Array([...testMessage, 7]); + const msgHash = keccak_256(tamperedMessage); + const sig = secp256k1.Signature.fromCompact(signature); + const isValid = secp256k1.verify(sig, msgHash, publicKey); + expect(isValid).toBe(false); + }); +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28e2672..c5f1493 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -175,6 +175,12 @@ importers: '@sovereign-sdk/utils': specifier: workspace:^ version: link:../utils + '@turnkey/api-key-stamper': + specifier: ^0.4.7 + version: 0.4.7 + '@turnkey/http': + specifier: ^3.5.1 + version: 3.6.0 ethers: specifier: ^6.15.0 version: 6.15.0 @@ -1186,6 +1192,22 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + '@turnkey/api-key-stamper@0.4.7': + resolution: {integrity: sha512-/0/kW7v+uCnmHnGMoHSXn4Vb/MxLAIivGxX/T0L4vVoIiJalQmqcCtgiWnPWZDiJNGjMKp+jd/8j6VXgbVVozg==} + engines: {node: '>=18.0.0'} + + '@turnkey/encoding@0.5.0': + resolution: {integrity: sha512-nRlKRQa6B5/xltGUKN1iKo4h4YC/0iFz0fAuFFZevc+YGDj7ddAP/3HkWmVvLmdoicUgs9rxvWbLRlgqPkbwzQ==} + engines: {node: '>=18.0.0'} + + '@turnkey/http@3.6.0': + resolution: {integrity: sha512-TpjZNpNWuJVDbnMGQva/kAuDheWDjZwS6BweeXuqQsfPf/pBeuqP0HVj6+3D2jQAQYNfQ1IE0snRURs+Uu0Inw==} + engines: {node: '>=18.0.0'} + + '@turnkey/webauthn-stamper@0.5.1': + resolution: {integrity: sha512-eBwceTStSSettBQsLo3X5eJEarcK9f20cGUdi6jOesXOP86iYEIgR4+aH2qyCQ3eaovj+Hl44UGngXueIm/tKg==} + engines: {node: '>=18.0.0'} + '@types/bun@1.2.19': resolution: {integrity: sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg==} @@ -1575,6 +1597,9 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} + cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} @@ -2468,6 +2493,9 @@ packages: engines: {node: '>=10'} hasBin: true + sha256-uint8array@0.10.7: + resolution: {integrity: sha512-1Q6JQU4tX9NqsDGodej6pkrUVQVNapLZnvkwIhddH/JqzBZF1fSaxSWNY6sziXBE8aEa2twtGkXUrwzGeZCMpQ==} + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -3762,6 +3790,27 @@ snapshots: '@tsconfig/node16@1.0.4': {} + '@turnkey/api-key-stamper@0.4.7': + dependencies: + '@noble/curves': 1.4.2 + '@turnkey/encoding': 0.5.0 + sha256-uint8array: 0.10.7 + + '@turnkey/encoding@0.5.0': {} + + '@turnkey/http@3.6.0': + dependencies: + '@turnkey/api-key-stamper': 0.4.7 + '@turnkey/encoding': 0.5.0 + '@turnkey/webauthn-stamper': 0.5.1 + cross-fetch: 3.2.0 + transitivePeerDependencies: + - encoding + + '@turnkey/webauthn-stamper@0.5.1': + dependencies: + sha256-uint8array: 0.10.7 + '@types/bun@1.2.19(@types/react@19.1.8)': dependencies: bun-types: 1.2.19(@types/react@19.1.8) @@ -4197,6 +4246,12 @@ snapshots: create-require@1.1.1: {} + cross-fetch@3.2.0: + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + cross-spawn@5.1.0: dependencies: lru-cache: 4.1.5 @@ -5070,6 +5125,8 @@ snapshots: semver@7.7.1: {} + sha256-uint8array@0.10.7: {} + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0