diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e8d2e3bf..3149f45b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ All notable changes to the Aptos TypeScript SDK will be captured in this file. This changelog is written by hand for now. It adheres to the format set out by [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). # Unreleased +- Added `verifySecp256k1Account` function to verify secp256k1 signatures to prove account ownership. Also enables public key recovery for such accounts. - node now no longer supports older than v20 - overriding cross spawn for patch diff --git a/src/api/account.ts b/src/api/account.ts index 5216af1ff..310ae5680 100644 --- a/src/api/account.ts +++ b/src/api/account.ts @@ -2,7 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 import { Account as AccountModule } from "../account"; -import { AccountAddress, PrivateKey, AccountAddressInput, createObjectAddress } from "../core"; +import { + AccountAddress, + PrivateKey, + AccountAddressInput, + createObjectAddress, + Secp256k1Signature, + AnyPublicKey, + AnySignature, +} from "../core"; import { AccountData, AnyNumber, @@ -11,6 +19,7 @@ import { GetAccountOwnedTokensFromCollectionResponse, GetAccountOwnedTokensQueryResponse, GetObjectDataQueryResponse, + HexInput, LedgerVersionArg, MoveModuleBytecode, MoveResource, @@ -39,6 +48,7 @@ import { getResources, getTransactions, lookupOriginalAccountAddress, + verifySecp256k1Account, } from "../internal/account"; import { APTOS_COIN, APTOS_FA, ProcessorType } from "../utils/const"; import { AptosConfig } from "./aptosConfig"; @@ -894,4 +904,44 @@ export class Account { async deriveAccountFromPrivateKey(args: { privateKey: PrivateKey }): Promise { return deriveAccountFromPrivateKey({ aptosConfig: this.config, ...args }); } + + /** + * Verifies a Secp256k1 account by checking the signature against the message and account's authentication key. + * + * This function takes a message and signature, and attempts to recover the public key that created the signature. + * It then verifies that the recovered public key matches the authentication key stored on-chain for the provided account address. + * + * If a recovery bit is provided, it will only attempt verification with that specific recovery bit. + * Otherwise, it will try all possible recovery bits (0-3) until it finds a match. + * + * @param args - The arguments for verifying the Secp256k1 account + * @param args.message - The message that was signed + * @param args.signature - The signature to verify (either raw hex or Secp256k1Signature object) + * @param args.recoveryBit - Optional specific recovery bit to use for verification + * @param args.accountAddress - The address of the account to verify + * @returns The recovered public key if verification succeeds + * @throws Error if verification fails or no matching public key is found + * + * @example + * ```typescript + * import { Aptos, AptosConfig, Network } from "@aptos-labs/ts-sdk"; + * + * const config = new AptosConfig({ network: Network.DEVNET }); + * const aptos = new Aptos(config); + * + * const publicKey = await aptos.verifySecp256k1Account({ + * message: "0x1234...", + * signature: "0x5678...", + * accountAddress: "0x1" + * }); + * ``` + */ + async verifySecp256k1Account(args: { + message: HexInput; + signature: Secp256k1Signature | AnySignature; + recoveryBit?: number; + accountAddress: AccountAddressInput; + }): Promise { + return verifySecp256k1Account({ aptosConfig: this.config, ...args }); + } } diff --git a/src/core/crypto/secp256k1.ts b/src/core/crypto/secp256k1.ts index 8ab2ae5cd..5bf204b63 100644 --- a/src/core/crypto/secp256k1.ts +++ b/src/core/crypto/secp256k1.ts @@ -4,6 +4,7 @@ import { sha3_256 } from "@noble/hashes/sha3"; import { secp256k1 } from "@noble/curves/secp256k1"; import { HDKey } from "@scure/bip32"; +import { bytesToNumberBE, inRange } from "@noble/curves/abstract/utils"; import { Serializable, Deserializer, Serializer } from "../../bcs"; import { Hex } from "../hex"; import { HexInput, PrivateKeyVariants } from "../../types"; @@ -13,6 +14,9 @@ import { PublicKey, VerifySignatureArgs } from "./publicKey"; import { Signature } from "./signature"; import { convertSigningMessage } from "./utils"; +const secp256k1P = BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"); +const secp256k1N = BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"); + /** * Represents a Secp256k1 ECDSA public key. * @@ -150,6 +154,35 @@ export class Secp256k1PublicKey extends PublicKey { static isInstance(publicKey: PublicKey): publicKey is Secp256k1PublicKey { return "key" in publicKey && (publicKey.key as any)?.data?.length === Secp256k1PublicKey.LENGTH; } + + /** + * Recover a Secp256k1 public key from a signature and message. + * + * @param args - The arguments for recovering the public key. + * @param args.signature - The signature to recover the public key from. + * @param args.message - The message that was signed. + * @param args.recoveryBit - The recovery bit to use for the public key. + */ + static fromSignatureAndMessage(args: { + signature: Secp256k1Signature; + message: HexInput; + recoveryBit: number; + }): Secp256k1PublicKey { + const { signature, message, recoveryBit } = args; + const signatureBytes: Uint8Array = signature.toUint8Array(); + + const r = bytesToNumberBE(signatureBytes.subarray(0, 32)); // Let r = int(sig[0:32]); fail if r ≥ p. + if (!inRange(r, BigInt(1), secp256k1P)) throw new Error("Invalid secp256k1 signature - r ≥ p"); + const s = bytesToNumberBE(signatureBytes.subarray(32, 64)); // Let s = int(sig[32:64]); fail if s ≥ n. + if (!inRange(s, BigInt(1), secp256k1N)) throw new Error("Invalid secp256k1 signature - s ≥ n"); + const nobleSig = new secp256k1.Signature(r, s); + + const messageToVerify = convertSigningMessage(message); + const messageBytes = Hex.fromHexInput(messageToVerify).toUint8Array(); + const messageSha3Bytes = sha3_256(messageBytes); + const publicKeyBytes = nobleSig.addRecoveryBit(recoveryBit).recoverPublicKey(messageSha3Bytes).toRawBytes(false); + return new Secp256k1PublicKey(publicKeyBytes); + } } /** @@ -367,6 +400,11 @@ export class Secp256k1Signature extends Signature { */ static readonly LENGTH = 64; + /** + * Automatically added when constructed or de-serialized. Used for type checking. + */ + private readonly signatureType = "secp256k1"; + /** * The signature bytes * @private @@ -417,4 +455,20 @@ export class Secp256k1Signature extends Signature { } // endregion + + /** + * Determines if the provided signature is a valid instance of a Secp256k1 signature. + * This function checks for the presence of a "data" property and validates the length of the signature data. + * + * @param signature - The signature to validate. + * @returns A boolean indicating whether the signature is a valid Secp256k1 signature. + */ + static isInstance(signature: any): signature is Secp256k1Signature { + return ( + "signatureType" in signature && + signature.signatureType === "secp256k1" && + "data" in signature && + (signature.data.data as any)?.length === Secp256k1Signature.LENGTH + ); + } } diff --git a/src/core/crypto/singleKey.ts b/src/core/crypto/singleKey.ts index 8ee12c2ff..386fa9712 100644 --- a/src/core/crypto/singleKey.ts +++ b/src/core/crypto/singleKey.ts @@ -1,5 +1,10 @@ import { Deserializer, Serializer } from "../../bcs"; -import { AnyPublicKeyVariant, AnySignatureVariant, SigningScheme as AuthenticationKeyScheme } from "../../types"; +import { + AnyPublicKeyVariant, + AnySignatureVariant, + SigningScheme as AuthenticationKeyScheme, + HexInput, +} from "../../types"; import { AuthenticationKey } from "../authenticationKey"; import { Ed25519PrivateKey, Ed25519PublicKey, Ed25519Signature } from "./ed25519"; import { AccountPublicKey, PublicKey, VerifySignatureArgs } from "./publicKey"; @@ -212,6 +217,30 @@ export class AnyPublicKey extends AccountPublicKey { static isInstance(publicKey: PublicKey): publicKey is AnyPublicKey { return "publicKey" in publicKey && "variant" in publicKey; } + + /** + * Recover a Secp256k1 AnyPublicKey from a signature and message. + * + * @param args.signature - The signature to recover the public key from. + * @param args.message - The message that was signed. + * @param args.recoveryBit - The recovery bit to use for the public key. + */ + static fromSecp256k1SignatureAndMessage(args: { + signature: AnySignature; + message: HexInput; + recoveryBit: number; + }): AnyPublicKey { + const { signature, message, recoveryBit } = args; + if (!Secp256k1Signature.isInstance(signature.signature)) { + throw new Error("AnySignature variant is not Secp256k1"); + } + const publicKey = Secp256k1PublicKey.fromSignatureAndMessage({ + signature: signature.signature, + message, + recoveryBit, + }); + return new AnyPublicKey(publicKey); + } } /** diff --git a/src/internal/account.ts b/src/internal/account.ts index 116a316d3..9f0c08942 100644 --- a/src/internal/account.ts +++ b/src/internal/account.ts @@ -17,6 +17,7 @@ import { GetAccountOwnedTokensFromCollectionResponse, GetAccountOwnedTokensQueryResponse, GetObjectDataQueryResponse, + HexInput, LedgerVersionArg, MoveModuleBytecode, MoveResource, @@ -29,7 +30,7 @@ import { } from "../types"; import { AccountAddress, AccountAddressInput } from "../core/accountAddress"; import { Account } from "../account"; -import { AnyPublicKey, Ed25519PublicKey, PrivateKey } from "../core/crypto"; +import { AnyPublicKey, AnySignature, Ed25519PublicKey, PrivateKey, Secp256k1Signature } from "../core/crypto"; import { queryIndexer } from "./general"; import { GetAccountCoinsCountQuery, @@ -815,3 +816,92 @@ export async function isAccountExist(args: { aptosConfig: AptosConfig; authKey: throw new Error(`Error while looking for an account info ${accountAddress.toString()}`); } } + +/** + * Verifies a Secp256k1 account by checking the signature against the message and account's authentication key. + * + * This function takes a message and signature, and attempts to recover the public key that created the signature. + * It then verifies that the recovered public key matches the authentication key stored on-chain for the provided account address. + * + * If a recovery bit is provided, it will only attempt verification with that specific recovery bit. + * Otherwise, it will try all possible recovery bits (0-3) until it finds a match. + * + * @param args - The arguments for verifying the Secp256k1 account + * @param args.aptosConfig - The configuration for connecting to the Aptos blockchain. + * @param args.message - The message that was signed + * @param args.signature - The signature to verify (either raw hex or Secp256k1Signature object) + * @param args.recoveryBit - Optional specific recovery bit to use for verification + * @param args.accountAddress - The address of the account to verify + * @returns The recovered public key if verification succeeds + * @throws Error if verification fails or no matching public key is found + * + * @example + * ```typescript + * import { AptosConfig, Network } from "@aptos-labs/ts-sdk"; + * + * const config = new AptosConfig({ network: Network.DEVNET }); + * + * const publicKey = await verifySecp256k1Account({ + * aptosConfig, + * message: "0x1234...", + * signature: "0x5678...", + * accountAddress: "0x1" + * }); + * ``` + */ +export async function verifySecp256k1Account(args: { + aptosConfig: AptosConfig; + message: HexInput; + signature: Secp256k1Signature | AnySignature; + accountAddress: AccountAddressInput; + recoveryBit?: number; +}): Promise { + const { aptosConfig, message, recoveryBit, accountAddress } = args; + const signature = AnySignature.isInstance(args.signature) ? args.signature : new AnySignature(args.signature); + + const { authentication_key: authKeyString } = await getInfo({ + aptosConfig, + accountAddress, + }); + const authKey = new AuthenticationKey({ data: authKeyString }); + + if (recoveryBit !== undefined) { + const publicKey = AnyPublicKey.fromSecp256k1SignatureAndMessage({ + signature, + message, + recoveryBit, + }); + const derivedAuthKey = publicKey.authKey(); + if (authKey.toStringWithoutPrefix() === derivedAuthKey.toStringWithoutPrefix()) { + return publicKey; + } + throw new Error( + // eslint-disable-next-line max-len + `Derived authentication key ${derivedAuthKey.toString()} does not match the authentication key ${authKey.toString()} for account ${accountAddress.toString()}`, + ); + } + + for (let i = 0; i < 4; i += 1) { + try { + const publicKey = AnyPublicKey.fromSecp256k1SignatureAndMessage({ + signature, + message, + recoveryBit: i, + }); + const derivedAuthKey = publicKey.authKey(); + if (authKey.toStringWithoutPrefix() === derivedAuthKey.toStringWithoutPrefix()) { + return publicKey; + } + } catch (e) { + if (e instanceof Error && e.message.includes("recovery id")) { + // eslint-disable-next-line no-continue + continue; + } + throw e; + } + } + + throw new Error( + `Failed to recover the public key matching authentication key ${authKey.toString()} for account ${accountAddress.toString()}`, + ); +} diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index b012d777d..1f0f34dcc 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { decode } from "js-base64"; -import { MoveStructId } from "../types"; +import { HexInput, MoveStructId } from "../types"; /** * Sleep for the specified amount of time in milliseconds. @@ -175,3 +175,11 @@ export const isEncodedStruct = ( typeof structObj.account_address === "string" && typeof structObj.module_name === "string" && typeof structObj.struct_name === "string"; + +/** + * Type guard to check if a value is of type HexInput + * @param value The value to check + */ +export function isHexInput(value: unknown): value is HexInput { + return value instanceof Uint8Array || typeof value === "string"; +} diff --git a/tests/e2e/api/account.test.ts b/tests/e2e/api/account.test.ts index 842bb3e09..337755732 100644 --- a/tests/e2e/api/account.test.ts +++ b/tests/e2e/api/account.test.ts @@ -11,6 +11,8 @@ import { SigningSchemeInput, U64, AccountAddress, + SingleKeyAccount, + Secp256k1PrivateKey, } from "../../../src"; import { getAptosClient } from "../helper"; @@ -301,6 +303,99 @@ describe("account api", () => { expect(tokens[0].current_token_data?.token_name).toBe("Test Token"); }); + test("it verifies a secp256k1 signature", async () => { + const config = new AptosConfig({ network: Network.LOCAL }); + const aptos = new Aptos(config); + const account = new SingleKeyAccount({ + privateKey: new Secp256k1PrivateKey( + "secp256k1-priv-0x1111111111111111111111111111111111111111111111111111111111111111", + ), + }); + await aptos.fundAccount({ accountAddress: account.accountAddress, amount: 100 }); + const message = new TextEncoder().encode("hello"); + const signature = account.sign(message); + + const pubKey = await aptos.verifySecp256k1Account({ + message, + signature, + accountAddress: account.accountAddress, + }); + expect(pubKey.toString()).toBe(account.publicKey.toString()); + }); + + test("verification of a secp256k1 signature fails with invalid message", async () => { + const config = new AptosConfig({ network: Network.LOCAL }); + const aptos = new Aptos(config); + const account = new SingleKeyAccount({ + privateKey: new Secp256k1PrivateKey( + "secp256k1-priv-0x1111111111111111111111111111111111111111111111111111111111111111", + ), + }); + await aptos.fundAccount({ accountAddress: account.accountAddress, amount: 100 }); + const message = new TextEncoder().encode("hello"); + const signature = account.sign(message); + + await expect( + aptos.verifySecp256k1Account({ + message: new TextEncoder().encode("hi"), + signature, + accountAddress: account.accountAddress, + }), + ).rejects.toThrow("Failed to recover the public key"); + }); + + test("verification of a secp256k1 signature fails with wrong address", async () => { + const config = new AptosConfig({ network: Network.LOCAL }); + const aptos = new Aptos(config); + const account = new SingleKeyAccount({ + privateKey: new Secp256k1PrivateKey( + "secp256k1-priv-0x1111111111111111111111111111111111111111111111111111111111111111", + ), + }); + await aptos.fundAccount({ accountAddress: account.accountAddress, amount: 100 }); + const message = new TextEncoder().encode("hello"); + const signature = account.sign(message); + + await expect( + aptos.verifySecp256k1Account({ + message, + signature, + accountAddress: "0x1", + }), + ).rejects.toThrow("Failed to recover the public key"); + }); + + test("verification of a secp256k1 signature fails with invalid recovery bit", async () => { + const config = new AptosConfig({ network: Network.LOCAL }); + const aptos = new Aptos(config); + const account = new SingleKeyAccount({ + privateKey: new Secp256k1PrivateKey( + "secp256k1-priv-0x1111111111111111111111111111111111111111111111111111111111111111", + ), + }); + await aptos.fundAccount({ accountAddress: account.accountAddress, amount: 100 }); + const message = new TextEncoder().encode("hello"); + const signature = account.sign(message); + + await expect( + aptos.verifySecp256k1Account({ + message, + signature, + accountAddress: account.accountAddress, + recoveryBit: 0, + }), + ).rejects.toThrow("does not match the authentication key"); + + await expect( + aptos.verifySecp256k1Account({ + message, + signature, + accountAddress: account.accountAddress, + recoveryBit: 3, + }), + ).rejects.toThrow("recovery id 2 or 3 invalid"); + }); + describe("it derives an account from a private key", () => { test("single sender ed25519", async () => { const config = new AptosConfig({ network: Network.LOCAL });