Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/signers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
131 changes: 131 additions & 0 deletions packages/signers/src/turnkey-signer.ts
Original file line number Diff line number Diff line change
@@ -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<TurnkeySigner> {

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<Uint8Array> {
return this._publicKey;
}

async sign(message: Uint8Array): Promise<Uint8Array> {
// 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);

}
}
}
164 changes: 164 additions & 0 deletions packages/signers/src/turnkey.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
})
Loading
Loading