Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add secp256k1 verify and pubKey recovery function #583

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 51 additions & 1 deletion src/api/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -11,6 +19,7 @@ import {
GetAccountOwnedTokensFromCollectionResponse,
GetAccountOwnedTokensQueryResponse,
GetObjectDataQueryResponse,
HexInput,
LedgerVersionArg,
MoveModuleBytecode,
MoveResource,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -894,4 +904,44 @@ export class Account {
async deriveAccountFromPrivateKey(args: { privateKey: PrivateKey }): Promise<AccountModule> {
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<AnyPublicKey> {
return verifySecp256k1Account({ aptosConfig: this.config, ...args });
}
}
54 changes: 54 additions & 0 deletions src/core/crypto/secp256k1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
*
Expand Down Expand Up @@ -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: {
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
signature: Secp256k1Signature;
message: HexInput;
recoveryBit: number;
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
}): 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);
}
}

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
);
}
}
31 changes: 30 additions & 1 deletion src/core/crypto/singleKey.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
92 changes: 91 additions & 1 deletion src/internal/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
GetAccountOwnedTokensFromCollectionResponse,
GetAccountOwnedTokensQueryResponse,
GetObjectDataQueryResponse,
HexInput,
LedgerVersionArg,
MoveModuleBytecode,
MoveResource,
Expand All @@ -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,
Expand Down Expand Up @@ -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
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
* @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<AnyPublicKey> {
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()}`,
);
}
10 changes: 9 additions & 1 deletion src/utils/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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";
}
Loading
Loading