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.

# 1.33.0 (2024-11-13)
- Allow optional provision of public keys in transaction simulation
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 });
}
}
35 changes: 33 additions & 2 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 { 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,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: {
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 { 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}`);
}
}
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
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);
}
}

/**
Expand Down
79 changes: 78 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 @@ -795,3 +803,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> {
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
const { aptosConfig, message, recoveryBit, accountAddress } = args;
let signature: HexInput | Secp256k1Signature;
if (args.signature instanceof AnySignature) {
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
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,
}),
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()}`,
);
}
48 changes: 48 additions & 0 deletions tests/e2e/api/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
SigningSchemeInput,
U64,
AccountAddress,
SingleKeyAccount,
Secp256k1PrivateKey,
} from "../../../src";
import { getAptosClient } from "../helper";

Expand Down Expand Up @@ -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 () => {
heliuchuan marked this conversation as resolved.
Show resolved Hide resolved
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 });
Expand Down
Loading