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 15 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.

- Add `gasProfile` function to `Move` class to allow for gas profiling of Aptos Move functions

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 @@ -874,4 +884,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: HexInput | Secp256k1Signature | AnySignature;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we accept AnySignature if we only verify for Secp256k1Account? why is Secp256k1Signature is not part of AnySignature? Also, why do we accept HexInput as a signature? dont we want to make sure the function accepts a valid signature?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, public key recovery should be in the signature to return. I don't think it belongs at the account level given you can't recover the private key

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because Secp256k1Account does not exist. SingleKeyAccount exists which returns AnySignature when it signs something. You can get the inner Secp256k1Signature via signature.signature, but you would still have to type check it into Secp256k1Signature. Having the function handle it seems better DevX

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gregnazario not sure what you are suggesting

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh and HexInput is again for convenience. The function will check signature validity.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also would expect this function to be in a different place than Account.

I think Secp256k1PublicKey.fromMessageAndSignature makes total sense (fromSignedMessage could be even more concise).

Ideally that's all the devs would need, and they could do the following:

const innerPublicKey = new Secp256k1PublicKey.fromSignedMessage({ message, signature });
const publicKey = new AnyPublicKey({ publicKey: innerPublicKey });
const derivedAddress = publicKey.authKey().derivedAddress.toString();

// Simple case
const isValid = derivedAddress === accountAddress;

// Handle rotated auth keys
const { authentication_key } = await aptos.getAccountInfo({ accountAddress });
const isValid = derivedAddress === authentication_key;

now.. how can we make this even easier / more concise?

  1. Going from Secp256k1PublicKey to AnyPublicKey is a bit annoying, maybe we can have the constructor there instead: AnyPublicKey.fromSecp256k1SignedMessage({ message, signature })
  2. The action of verifying the publicKey is associated to an account from its address is agnostic to the signature scheme, so we could have verifyAuthenticationKey({ authKey, accountAddress }) which could live in the api to avoid dependency cycles

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really hate the AnyPublicKey name ugh

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the problem is you need to look up the auth key with the address, and only via the auth key can you figure out which public key is correct. If a recovery is provided 2 public keys would be returned.

I'm down for AnyPublicKey.fromSecp256k1SignedMessage({ message, signature }) though

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I re-evaluated it and did similar here aptos-labs/aptos-go-sdk#108

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the problem is you need to look up the auth key with the address, and only via the auth key can you figure out which public key is correct. If a recovery is provided 2 public keys would be returned.

Oh I see, gotcha makes sense

recoveryBit?: number;
accountAddress: AccountAddressInput;
}): Promise<AnyPublicKey> {
return verifySecp256k1Account({ aptosConfig: this.config, ...args });
}
}
61 changes: 54 additions & 7 deletions src/core/crypto/secp256k1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
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";
import { HexInput, isHexInput, PrivateKeyVariants } from "../../types";
import { isValidBIP44Path, mnemonicToSeed } from "./hdKey";
import { PrivateKey } from "./privateKey";
import { PublicKey, VerifySignatureArgs } from "./publicKey";
import { PublicKey } 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 @@ -53,7 +57,7 @@ export class Secp256k1PublicKey extends PublicKey {
* @param args.message - The message that was signed.
* @param args.signature - The signature to verify against the public key.
*/
verifySignature(args: VerifySignatureArgs): boolean {
verifySignature(args: { message: HexInput; signature: Secp256k1Signature }): boolean {
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
const { message, signature } = args;
const messageToVerify = convertSigningMessage(message);
const messageBytes = Hex.fromHexInput(messageToVerify).toUint8Array();
Expand Down Expand Up @@ -124,6 +128,36 @@ 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: HexInput | Secp256k1Signature;
message: HexInput;
recoveryBit: number;
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
}): Secp256k1PublicKey {
const { message, recoveryBit } = args;
const signature = isHexInput(args.signature) ? new Secp256k1Signature(args.signature) : args.signature;
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 @@ -318,7 +352,7 @@ export class Secp256k1Signature extends Signature {
* The signature bytes
* @private
*/
private readonly data: Hex;
private readonly secp256k1SigData: Hex;
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved

// region Constructors

Expand All @@ -335,23 +369,23 @@ export class Secp256k1Signature extends Signature {
`Signature length should be ${Secp256k1Signature.LENGTH}, received ${data.toUint8Array().length}`,
);
}
this.data = data;
this.secp256k1SigData = data;
}

// endregion

// region Signature

toUint8Array(): Uint8Array {
return this.data.toUint8Array();
return this.secp256k1SigData.toUint8Array();
}

// endregion

// region Serializable

serialize(serializer: Serializer): void {
serializer.serializeBytes(this.data.toUint8Array());
serializer.serializeBytes(this.secp256k1SigData.toUint8Array());
}

static deserialize(deserializer: Deserializer): Secp256k1Signature {
Expand All @@ -360,4 +394,17 @@ 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 (
"secp256k1SigData" in signature && (signature.secp256k1SigData.data as any)?.length === Secp256k1Signature.LENGTH
);
}
}
16 changes: 16 additions & 0 deletions src/core/crypto/signatureUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { HexInput, isHexInput } from "../../types";
import { Secp256k1Signature } from "./secp256k1";
import { AnySignature } from "./singleKey";

export function toSecp256k1Signature(signature: HexInput | Secp256k1Signature | AnySignature): Secp256k1Signature {
if (isHexInput(signature)) {
return new Secp256k1Signature(signature);
}
if (AnySignature.isInstance(signature)) {
if (Secp256k1Signature.isInstance(signature.signature)) {
return signature.signature;
}
throw new Error("AnySignature variant is not Secp256k1");
}
return signature;
}
11 changes: 6 additions & 5 deletions src/core/crypto/singleKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ export class AnySignature extends Signature {
/**
* Index of the underlying enum variant
*/
private readonly variant: AnySignatureVariant;
private readonly anySignatureVariant: AnySignatureVariant;
gregnazario marked this conversation as resolved.
Show resolved Hide resolved

// region Constructors

Expand All @@ -208,11 +208,11 @@ export class AnySignature extends Signature {
this.signature = signature;

if (signature instanceof Ed25519Signature) {
this.variant = AnySignatureVariant.Ed25519;
this.anySignatureVariant = AnySignatureVariant.Ed25519;
} else if (signature instanceof Secp256k1Signature) {
this.variant = AnySignatureVariant.Secp256k1;
this.anySignatureVariant = AnySignatureVariant.Secp256k1;
} else if (signature instanceof KeylessSignature) {
this.variant = AnySignatureVariant.Keyless;
this.anySignatureVariant = AnySignatureVariant.Keyless;
} else {
throw new Error("Unsupported signature type");
}
Expand All @@ -237,7 +237,7 @@ export class AnySignature extends Signature {
// region Serializable

serialize(serializer: Serializer): void {
serializer.serializeU32AsUleb128(this.variant);
serializer.serializeU32AsUleb128(this.anySignatureVariant);
this.signature.serialize(serializer);
}

Expand All @@ -264,6 +264,7 @@ export class AnySignature extends Signature {

static isInstance(signature: Signature): signature is AnySignature {
return (
"anySignatureVariant" in signature &&
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
"signature" in signature &&
typeof signature.signature === "object" &&
signature.signature !== null &&
Expand Down
103 changes: 102 additions & 1 deletion src/internal/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
GetAccountOwnedTokensFromCollectionResponse,
GetAccountOwnedTokensQueryResponse,
GetObjectDataQueryResponse,
HexInput,
LedgerVersionArg,
MoveModuleBytecode,
MoveResource,
Expand All @@ -28,7 +29,14 @@ 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,
Secp256k1PublicKey,
Secp256k1Signature,
} from "../core/crypto";
import { queryIndexer } from "./general";
import {
GetAccountCoinsCountQuery,
Expand Down Expand Up @@ -56,6 +64,7 @@ import { CurrentFungibleAssetBalancesBoolExp } from "../types/generated/types";
import { getTableItem } from "./table";
import { APTOS_COIN } from "../utils";
import { AptosApiError } from "../errors";
import { toSecp256k1Signature } from "../core/crypto/signatureUtils";

/**
* Retrieves account information for a specified account address.
Expand Down Expand Up @@ -795,3 +804,95 @@ 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: HexInput | Secp256k1Signature | AnySignature;
recoveryBit?: number;
accountAddress: AccountAddressInput;
}): Promise<AnyPublicKey> {
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
const { aptosConfig, message, recoveryBit, accountAddress } = args;
const signature = toSecp256k1Signature(args.signature);
const { authentication_key: authKeyString } = await getInfo({
aptosConfig,
accountAddress,
});
const authKey = new AuthenticationKey({ data: authKeyString });

if (recoveryBit !== undefined) {
const publicKey = new AnyPublicKey(
Secp256k1PublicKey.fromSignatureAndMessage({
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 = new AnyPublicKey(
Secp256k1PublicKey.fromSignatureAndMessage({
signature,
message,
recoveryBit: i,
}),
gregnazario marked this conversation as resolved.
Show resolved Hide resolved
);
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()}`,
);
}
Loading
Loading