@mysten/sui
Version:
Sui TypeScript API(Work in Progress)
307 lines (277 loc) • 11 kB
text/typescript
// Copyright (c) Mysten Labs, Inc.
// SPDX-License-Identifier: Apache-2.0
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 { PasskeyAuthenticator } from '../../bcs/bcs.js';
import type { IntentScope, SignatureWithBytes } from '../../cryptography/index.js';
import { messageWithIntent, SIGNATURE_SCHEME_TO_FLAG, Signer } from '../../cryptography/index.js';
import type { PublicKey } from '../../cryptography/publickey.js';
import type { SignatureScheme } from '../../cryptography/signature-scheme.js';
import {
parseDerSPKI,
PASSKEY_PUBLIC_KEY_SIZE,
PASSKEY_SIGNATURE_SIZE,
PasskeyPublicKey,
} from './publickey.js';
import type { AuthenticationCredential, RegistrationCredential } from './types.js';
type DeepPartialConfigKeys = 'rp' | 'user' | 'authenticatorSelection';
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
export type BrowserPasswordProviderOptions = Pick<
DeepPartial<PublicKeyCredentialCreationOptions>,
DeepPartialConfigKeys
> &
Omit<
Partial<PublicKeyCredentialCreationOptions>,
DeepPartialConfigKeys | 'pubKeyCredParams' | 'challenge'
>;
export interface PasskeyProvider {
create(): Promise<RegistrationCredential>;
get(challenge: Uint8Array): Promise<AuthenticationCredential>;
}
// Default browser implementation
export class BrowserPasskeyProvider implements PasskeyProvider {
#name: string;
#options: BrowserPasswordProviderOptions;
constructor(name: string, options: BrowserPasswordProviderOptions) {
this.#name = name;
this.#options = options;
}
async create(): Promise<RegistrationCredential> {
return (await navigator.credentials.create({
publicKey: {
timeout: this.#options.timeout ?? 60000,
...this.#options,
rp: {
name: this.#name,
...this.#options.rp,
},
user: {
name: this.#name,
displayName: this.#name,
...this.#options.user,
id: randomBytes(10),
},
challenge: new TextEncoder().encode('Create passkey wallet on Sui'),
pubKeyCredParams: [{ alg: -7, type: 'public-key' }],
authenticatorSelection: {
authenticatorAttachment: 'cross-platform',
residentKey: 'required',
requireResidentKey: true,
userVerification: 'required',
...this.#options.authenticatorSelection,
},
},
})) as RegistrationCredential;
}
async get(challenge: Uint8Array): Promise<AuthenticationCredential> {
return (await navigator.credentials.get({
publicKey: {
challenge,
userVerification: this.#options.authenticatorSelection?.userVerification || 'required',
timeout: this.#options.timeout ?? 60000,
},
})) as AuthenticationCredential;
}
}
/**
* @experimental
* 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 provider: PasskeyProvider;
/**
* Get the key scheme of passkey,
*/
getKeyScheme(): SignatureScheme {
return 'Passkey';
}
/**
* Creates an instance of Passkey signer. If no passkey wallet had created before,
* use `getPasskeyInstance`. For example:
* ```
* 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) {
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.
*
* @param provider - the passkey provider.
* @returns the passkey instance.
*/
static async getPasskeyInstance(provider: PasskeyProvider): Promise<PasskeyKeypair> {
// create a passkey secp256r1 with the provider.
const credential = await provider.create();
if (!credential.response.getPublicKey()) {
throw new Error('Invalid credential create response');
} else {
const derSPKI = credential.response.getPublicKey()!;
const pubkeyUncompressed = parseDerSPKI(new Uint8Array(derSPKI));
const pubkey = secp256r1.ProjectivePoint.fromHex(pubkeyUncompressed);
const pubkeyCompressed = pubkey.toRawBytes(true);
return new PasskeyKeypair(pubkeyCompressed, provider);
}
}
/**
* Return the public key for this passkey.
*/
getPublicKey(): 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) {
// 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).
const authenticatorData = new Uint8Array(credential.response.authenticatorData);
const clientDataJSON = new Uint8Array(credential.response.clientDataJSON); // response.clientDataJSON is already UTF-8 encoded JSON
const decoder = new TextDecoder();
const clientDataJSONString: string = decoder.decode(clientDataJSON);
// parse the signature from DER format, normalize and convert to compressed format (33 bytes).
const sig = secp256r1.Signature.fromDER(new Uint8Array(credential.response.signature));
const normalized = sig.normalizeS().toCompactRawBytes();
if (
normalized.length !== PASSKEY_SIGNATURE_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);
arr.set([SIGNATURE_SCHEME_TO_FLAG['Secp256r1']]);
arr.set(normalized, 1);
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({
authenticatorData: authenticatorData,
clientDataJson: clientDataJSONString,
userSignature: arr,
}).toBytes();
}
/**
* This overrides the base class implementation that accepts the raw bytes and signs its
* digest of the intent message, then serialize it with the passkey flag.
*/
async signWithIntent(bytes: Uint8Array, intent: IntentScope): Promise<SignatureWithBytes> {
// prepend it into an intent message and computes the digest.
const intentMessage = messageWithIntent(intent, bytes);
const digest = blake2b(intentMessage, { dkLen: 32 });
// sign the digest.
const signature = await this.sign(digest);
// prepend with the passkey flag.
const serializedSignature = new Uint8Array(1 + signature.length);
serializedSignature.set([SIGNATURE_SCHEME_TO_FLAG[this.getKeyScheme()]]);
serializedSignature.set(signature, 1);
return {
signature: toBase64(serializedSignature),
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 `findCommonPublicKey`.
*
* 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 commonPk = findCommonPublicKey(possiblePks, possiblePks2);
* const signer = new PasskeyKeypair(provider, commonPk.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 findCommonPublicKey(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]);
}