Skip to content

Add secp256k1 verify and pubKey recovery function #583

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

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Changes from 1 commit
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
Next Next commit
Add secp256k1 verify and pubKey recovery function
heliuchuan authored and gregnazario committed Jan 14, 2025
commit 72b8470b9332ba454e328d2b0c6b446e769c760e
52 changes: 51 additions & 1 deletion src/api/account.ts
Original file line number Diff line number Diff line change
@@ -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<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 });
}
}
35 changes: 33 additions & 2 deletions src/core/crypto/secp256k1.ts
Original file line number Diff line number Diff line change
@@ -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 { 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.
*
@@ -69,7 +73,7 @@ export class Secp256k1PublicKey extends PublicKey {
* @group Implementation
* @category Serialization
*/
verifySignature(args: VerifySignatureArgs): boolean {
verifySignature(args: { message: HexInput; signature: Secp256k1Signature }): boolean {
const { message, signature } = args;
const messageToVerify = convertSigningMessage(message);
const messageBytes = Hex.fromHexInput(messageToVerify).toUint8Array();
@@ -150,6 +154,33 @@ export class Secp256k1PublicKey extends PublicKey {
static isInstance(publicKey: PublicKey): publicKey is Secp256k1PublicKey {
return "key" in publicKey && (publicKey.key as any)?.data?.length === Secp256k1PublicKey.LENGTH;
}

static fromSignatureAndMessage(args: {
signature: HexInput | Secp256k1Signature;
message: HexInput;
recoveryBit: number;
}): Secp256k1PublicKey {
const { signature, message, recoveryBit } = args;
let signatureBytes: Uint8Array;
if (signature instanceof Secp256k1Signature) {
signatureBytes = signature.toUint8Array();
} else {
signatureBytes = Hex.fromHexInput(signature).toUint8Array();
if (signatureBytes.length !== Secp256k1Signature.LENGTH) {
throw new Error(`Signature length should be ${Secp256k1Signature.LENGTH}`);
}
}
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 sig = new secp256k1.Signature(r, s);
const messageToVerify = convertSigningMessage(message);
const messageBytes = Hex.fromHexInput(messageToVerify).toUint8Array();
const messageSha3Bytes = sha3_256(messageBytes);
const publicKeyBytes = sig.addRecoveryBit(recoveryBit).recoverPublicKey(messageSha3Bytes).toRawBytes(false);
return new Secp256k1PublicKey(publicKeyBytes);
}
}

/**
79 changes: 78 additions & 1 deletion src/internal/account.ts
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ import {
GetAccountOwnedTokensFromCollectionResponse,
GetAccountOwnedTokensQueryResponse,
GetObjectDataQueryResponse,
HexInput,
LedgerVersionArg,
MoveModuleBytecode,
MoveResource,
@@ -29,7 +30,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,
@@ -815,3 +823,72 @@ export async function isAccountExist(args: { aptosConfig: AptosConfig; authKey:
throw new Error(`Error while looking for an account info ${accountAddress.toString()}`);
}
}

export async function verifySecp256k1Account(args: {
aptosConfig: AptosConfig;
message: HexInput;
signature: HexInput | Secp256k1Signature | AnySignature;
recoveryBit?: number;
accountAddress: AccountAddressInput;
}): Promise<AnyPublicKey> {
const { aptosConfig, message, recoveryBit, accountAddress } = args;
let signature: HexInput | Secp256k1Signature;
if (args.signature instanceof AnySignature) {
if (args.signature.signature instanceof Secp256k1Signature) {
signature = args.signature.signature;
} else {
throw new Error("Invalid signature type");
}
} else {
signature = 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,
}),
);
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()}`,
);
}
48 changes: 48 additions & 0 deletions tests/e2e/api/account.test.ts
Original file line number Diff line number Diff line change
@@ -11,6 +11,8 @@ import {
SigningSchemeInput,
U64,
AccountAddress,
SingleKeyAccount,
Secp256k1PrivateKey,
} from "../../../src";
import { getAptosClient } from "../helper";

@@ -301,6 +303,52 @@ 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());

await expect(
aptos.verifySecp256k1Account({
message: new TextEncoder().encode("hi"),
signature,
accountAddress: account.accountAddress,
}),
).rejects.toThrow("Failed to recover the public key");

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 });