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 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
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;
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) {
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
) {
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);

// 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);
* 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 {
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