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

sdk: add passkey recovery option #20722

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
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
122 changes: 111 additions & 11 deletions sdk/typescript/src/keypairs/passkey/keypair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { toBase64 } from '@mysten/bcs';
import { secp256r1 } from '@noble/curves/p256';
import { blake2b } from '@noble/hashes/blake2b';
import { sha256 } from '@noble/hashes/sha256';
import { randomBytes } from '@noble/hashes/utils';
import type {
AuthenticationCredential,
Expand Down Expand Up @@ -98,7 +99,7 @@ export class BrowserPasskeyProvider implements PasskeyProvider {
* A passkey signer used for signing transactions. This is a client side implementation for [SIP-9](https://github.com/sui-foundation/sips/blob/main/sips/sip-9.md).
*/
export class PasskeyKeypair extends Signer {
private publicKey: Uint8Array;
private publicKey?: Uint8Array;
Copy link
Contributor

Choose a reason for hiding this comment

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

I can't figure out why we want to change this. We are asserting that this is set everywhere its used, and I don't see any examples of constructing a PasskeyKeypair without a publicKey

Copy link
Contributor Author

Choose a reason for hiding this comment

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

you are right! it was somehow in my previous iteration on this. reverted.

private provider: PasskeyProvider;

/**
Expand All @@ -109,20 +110,32 @@ export class PasskeyKeypair extends Signer {
}

/**
* Creates an instance of Passkey signer. It's expected to call the static `getPasskeyInstance` method to create an instance.
* For example:
* Creates an instance of Passkey signer. If no passkey wallet had created before,
* use `getPasskeyInstance`. For example:
* ```
* const signer = await PasskeyKeypair.getPasskeyInstance();
* let provider = new BrowserPasskeyProvider('Sui Passkey Example',{
* rpName: 'Sui Passkey Example',
* rpId: window.location.hostname,
* } as BrowserPasswordProviderOptions);
* const signer = await PasskeyKeypair.getPasskeyInstance(provider);
* ```
*
* If there are existing passkey wallet, use `signAndRecover` to identify the correct
* public key and then initialize the instance. See usage in `signAndRecover`.
*/
constructor(publicKey: Uint8Array, provider: PasskeyProvider) {
constructor(provider: PasskeyProvider, publicKey?: Uint8Array) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure who is using this so far, but this is a breaking change

Copy link
Contributor Author

Choose a reason for hiding this comment

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

removed!

super();
this.publicKey = publicKey;
this.provider = provider;
}

/**
* Creates an instance of Passkey signer invoking the passkey from navigator.
* Note that this will invoke the passkey device to create a fresh credential.
* Should only be called if passkey wallet is created for the first time.
* todo: should rename this to `createFreshPasskeyInstance`?
* @param provider - the passkey provider.
* @returns the passkey instance.
*/
static async getPasskeyInstance(provider: PasskeyProvider): Promise<PasskeyKeypair> {
// create a passkey secp256r1 with the provider.
Expand All @@ -135,23 +148,23 @@ export class PasskeyKeypair extends Signer {
const pubkeyUncompressed = parseDerSPKI(new Uint8Array(derSPKI));
const pubkey = secp256r1.ProjectivePoint.fromHex(pubkeyUncompressed);
const pubkeyCompressed = pubkey.toRawBytes(true);
return new PasskeyKeypair(pubkeyCompressed, provider);
return new PasskeyKeypair(provider, pubkeyCompressed);
}
}

/**
* Return the public key for this passkey.
*/
getPublicKey(): PublicKey {
return new PasskeyPublicKey(this.publicKey);
return new PasskeyPublicKey(this.publicKey!);
}

/**
* Return the signature for the provided data (i.e. blake2b(intent_message)).
* This is sent to passkey as the challenge field.
*/
async sign(data: Uint8Array) {
// sendss the passkey to sign over challenge as the data.
// asks the passkey to sign over challenge as the data.
const credential = await this.provider.get(data);

// parse authenticatorData (as bytes), clientDataJSON (decoded as string).
Expand All @@ -166,16 +179,16 @@ export class PasskeyKeypair extends Signer {

if (
normalized.length !== PASSKEY_SIGNATURE_SIZE ||
this.publicKey.length !== PASSKEY_PUBLIC_KEY_SIZE
this.publicKey!.length !== PASSKEY_PUBLIC_KEY_SIZE
Copy link
Contributor

Choose a reason for hiding this comment

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

not needed anymore

) {
throw new Error('Invalid signature or public key length');
}

// construct userSignature as flag || sig || pubkey for the secp256r1 signature.
const arr = new Uint8Array(1 + normalized.length + this.publicKey.length);
const arr = new Uint8Array(1 + normalized.length + this.publicKey!.length);
arr.set([SIGNATURE_SCHEME_TO_FLAG['Secp256r1']]);
arr.set(normalized, 1);
arr.set(this.publicKey, 1 + normalized.length);
arr.set(this.publicKey!, 1 + normalized.length);
Copy link
Contributor

Choose a reason for hiding this comment

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

this assertion shouldn't be needed anymore


// serialize all fields into a passkey signature according to https://github.com/sui-foundation/sips/blob/main/sips/sip-9.md#signature-encoding
return PasskeyAuthenticator.serialize({
Expand Down Expand Up @@ -206,4 +219,91 @@ export class PasskeyKeypair extends Signer {
bytes: toBase64(bytes),
};
}

/**
* Given a message, asks the passkey device to sign it and return all (up to 4) possible public keys.
* See: https://bitcoin.stackexchange.com/questions/81232/how-is-public-key-extracted-from-message-digital-signature-address
*
* This is useful if the user previously created passkey wallet with the origin, but the wallet session
* does not have the public key / address. By calling this method twice with two different messages, the
* wallet can compare the returned public keys and uniquely identify the previously created passkey wallet
* using `findUniquePublicKey`.
*
* Alternatively, one call can be made and all possible public keys should be checked onchain to see if
* there is any assets.
*
* Once the correct public key is identified, a passkey instance can then be initialized with this public key.
*
* Example usage to recover wallet with two signing calls:
* ```
* let provider = new BrowserPasskeyProvider('Sui Passkey Example',{
* rpName: 'Sui Passkey Example',
* rpId: window.location.hostname,
* } as BrowserPasswordProviderOptions);
* const testMessage = new TextEncoder().encode('Hello world!');
* const possiblePks = await PasskeyKeypair.signAndRecover(provider, testMessage);
* const testMessage2 = new TextEncoder().encode('Hello world 2!');
* const possiblePks2 = await PasskeyKeypair.signAndRecover(provider, testMessage2);
* const uniquePk = findUniquePublicKey(possiblePks, possiblePks2);
Copy link
Contributor

@hayes-mysten hayes-mysten Jan 8, 2025

Choose a reason for hiding this comment

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

unique seems backwards here, you want the non-unique key right? We can probably add an actual implementation in the example like possiblePks.find(pk => !!possiblePks2.find(pk2 => pk.equals(pk2))

Copy link
Contributor

Choose a reason for hiding this comment

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

ah, didn't see we actually implemented this below

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I want to find the unique public key - since each one signature will recover to up to 4 public keys, only the unique one would be the desired one.

Copy link
Contributor

Choose a reason for hiding this comment

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

the code is finding the matching keys from the 2 lists, which is the opposite of unique

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah i see where the confusion is, i meant to say there should be exactly one common key that exists in both list.
what about findCommonPublicKey ?

Copy link
Contributor

Choose a reason for hiding this comment

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

that sounds good to me!

* const signer = new PasskeyKeypair(provider, uniquePk.toRawBytes());
* ```
*
* @param provider - the passkey provider.
* @param message - the message to sign.
* @returns all possible public keys.
*/
static async signAndRecover(
provider: PasskeyProvider,
message: Uint8Array,
): Promise<PublicKey[]> {
const credential = await provider.get(message);
const fullMessage = messageFromAssertionResponse(credential.response);
const sig = secp256r1.Signature.fromDER(new Uint8Array(credential.response.signature));

const res = [];
for (let i = 0; i < 4; i++) {
const s = sig.addRecoveryBit(i);
try {
const pubkey = s.recoverPublicKey(sha256(fullMessage));
const pk = new PasskeyPublicKey(pubkey.toRawBytes(true));
res.push(pk);
} catch {
continue;
}
}
return res;
}
}

/**
* Finds the unique public key that exists in both arrays, throws error if the common
* pubkey does not equal to one.
*
* @param arr1 - The first pubkeys array.
* @param arr2 - The second pubkeys array.
* @returns The only common pubkey in both arrays.
*/
export function findUniquePublicKey(arr1: PublicKey[], arr2: PublicKey[]): PublicKey {
Copy link
Contributor

Choose a reason for hiding this comment

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

same comment as above, unique seems like the wrong term here.

const matchingPubkeys: PublicKey[] = [];
for (const pubkey1 of arr1) {
for (const pubkey2 of arr2) {
if (pubkey1.equals(pubkey2)) {
matchingPubkeys.push(pubkey1);
}
}
}
if (matchingPubkeys.length !== 1) {
throw new Error('No unique public key found');
}
return matchingPubkeys[0];
}

/**
* Constructs the message that the passkey signature is produced over as authenticatorData || sha256(clientDataJSON).
*/
function messageFromAssertionResponse(response: AuthenticatorAssertionResponse): Uint8Array {
const authenticatorData = new Uint8Array(response.authenticatorData);
const clientDataJSON = new Uint8Array(response.clientDataJSON);
const clientDataJSONDigest = sha256(clientDataJSON);
return new Uint8Array([...authenticatorData, ...clientDataJSONDigest]);
}
28 changes: 27 additions & 1 deletion sdk/typescript/test/unit/cryptography/passkey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
import { bcs } from '../../../src/bcs';
import { messageWithIntent } from '../../../src/cryptography';
import { PasskeyKeypair } from '../../../src/keypairs/passkey';
import { PasskeyProvider } from '../../../src/keypairs/passkey/keypair';
import { findUniquePublicKey, PasskeyProvider } from '../../../src/keypairs/passkey/keypair';
import {
parseSerializedPasskeySignature,
PasskeyPublicKey,
Expand Down Expand Up @@ -327,4 +327,30 @@ describe('passkey signer E2E testing', () => {
const isValid = await pubkey.verifyTransaction(txBytes, sig);
expect(isValid).toBe(true);
});

it('should sign and recover to an unique public key', async () => {
const sk = secp256r1.utils.randomPrivateKey();
const pk = secp256r1.getPublicKey(sk);
const authenticatorData = new Uint8Array([]);
const mockProvider = new MockPasskeySigner({
sk: sk,
pk: pk,
authenticatorData: authenticatorData,
});

const signer = await PasskeyKeypair.getPasskeyInstance(mockProvider);
const address = signer.getPublicKey().toSuiAddress();

const testMessage = new TextEncoder().encode('Hello world!');
const possiblePks = await PasskeyKeypair.signAndRecover(mockProvider, testMessage);

const testMessage2 = new TextEncoder().encode('Hello world 2!');
const possiblePks2 = await PasskeyKeypair.signAndRecover(mockProvider, testMessage2);

const uniquePk = findUniquePublicKey(possiblePks, possiblePks2);
const signer2 = new PasskeyKeypair(mockProvider, uniquePk.toRawBytes());

// the address from recovered pk is the same as the one constructed from the same mock provider
expect(signer2.getPublicKey().toSuiAddress()).toEqual(address);
});
});
Loading