-
Notifications
You must be signed in to change notification settings - Fork 11.2k
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
|
@@ -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; | ||
|
||
/** | ||
|
@@ -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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. | ||
|
@@ -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). | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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({ | ||
|
@@ -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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah, didn't see we actually implemented this below There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]); | ||
} |
There was a problem hiding this comment.
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 publicKeyThere was a problem hiding this comment.
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.