sui-svelte-wallet-kit
Version:
Svelte 5 wallet kit for Sui: connect wallets, manage accounts, SuiNS, balance, sign transactions/messages
289 lines (288 loc) • 12.1 kB
JavaScript
/**
* PasskeyService
* Handles WebAuthn operations for passkey creation and authentication
*/
import { toBase64, fromBase64 } from '@mysten/sui/utils';
import { PasskeyError, PasskeyErrorCode } from './types.js';
// COSE algorithm identifiers
const COSE_ALG_ES256 = -7; // ECDSA with P-256 and SHA-256 (secp256r1)
const COSE_ALG_RS256 = -257; // RSASSA-PKCS1-v1_5 with SHA-256
/**
* PasskeyService class for WebAuthn passkey operations
*/
export class PasskeyService {
config;
constructor(config) {
this.validateConfig(config);
this.config = config;
}
/**
* Validate passkey configuration
*/
validateConfig(config) {
if (!config.rpId) {
throw new PasskeyError(PasskeyErrorCode.INVALID_CONFIG, 'PasskeyConfig requires rpId (Relying Party Identifier)');
}
if (!config.rpName) {
throw new PasskeyError(PasskeyErrorCode.INVALID_CONFIG, 'PasskeyConfig requires rpName (Relying Party Name)');
}
}
/**
* Check if WebAuthn is supported in the current browser
*/
static isSupported() {
if (typeof window === 'undefined')
return false;
if (typeof navigator === 'undefined')
return false;
if (!navigator.credentials)
return false;
if (typeof PublicKeyCredential === 'undefined')
return false;
return true;
}
/**
* Check if platform authenticator is available (Face ID, Touch ID, Windows Hello)
*/
static async isPlatformAuthenticatorAvailable() {
if (!PasskeyService.isSupported())
return false;
try {
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
}
catch {
return false;
}
}
/**
* Create a new passkey credential
*/
async createCredential(options) {
if (!PasskeyService.isSupported()) {
throw new PasskeyError(PasskeyErrorCode.WEBAUTHN_NOT_SUPPORTED, 'WebAuthn is not supported in this browser');
}
// Generate random user ID
const userId = new Uint8Array(32);
crypto.getRandomValues(userId);
// Generate random challenge
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const publicKeyCredentialCreationOptions = {
challenge,
rp: {
id: options.rpId,
name: options.rpName
},
user: {
id: userId,
name: options.userName,
displayName: options.userDisplayName || options.userName
},
pubKeyCredParams: [
{
type: 'public-key',
alg: COSE_ALG_ES256 // secp256r1 (P-256) - preferred for Sui
},
{
type: 'public-key',
alg: COSE_ALG_RS256 // RSA - fallback for compatibility
}
],
authenticatorSelection: {
authenticatorAttachment: options.authenticatorAttachment || 'platform',
userVerification: 'required',
residentKey: 'preferred',
requireResidentKey: false
},
timeout: options.timeout || 60000,
attestation: 'none'
};
try {
const credential = (await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions
}));
if (!credential) {
throw new PasskeyError(PasskeyErrorCode.CREDENTIAL_CREATE_FAILED, 'Failed to create passkey credential');
}
const response = credential.response;
const publicKey = this.extractPublicKey(response);
const suiAddress = await this.deriveSuiAddress(publicKey);
return {
credentialId: toBase64(new Uint8Array(credential.rawId)),
publicKey,
suiAddress,
createdAt: Date.now()
};
}
catch (error) {
if (error instanceof PasskeyError)
throw error;
const domError = error;
if (domError.name === 'NotAllowedError') {
throw new PasskeyError(PasskeyErrorCode.USER_CANCELLED, 'User cancelled the passkey creation', domError);
}
if (domError.name === 'AbortError' || domError.name === 'TimeoutError') {
throw new PasskeyError(PasskeyErrorCode.TIMEOUT, 'Passkey creation timed out', domError);
}
throw new PasskeyError(PasskeyErrorCode.CREDENTIAL_CREATE_FAILED, `Failed to create passkey: ${domError.message}`, domError);
}
}
/**
* Authenticate with existing passkey credential
*/
async authenticate(options) {
if (!PasskeyService.isSupported()) {
throw new PasskeyError(PasskeyErrorCode.WEBAUTHN_NOT_SUPPORTED, 'WebAuthn is not supported in this browser');
}
// Generate random challenge
const challenge = new Uint8Array(32);
crypto.getRandomValues(challenge);
const publicKeyCredentialRequestOptions = {
challenge,
rpId: options.rpId,
timeout: options.timeout || 60000,
userVerification: 'required'
};
// If credentialId is provided, use it in allowCredentials
if (options.credentialId) {
publicKeyCredentialRequestOptions.allowCredentials = [
{
type: 'public-key',
id: fromBase64(options.credentialId)
}
];
}
try {
const credential = (await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
}));
if (!credential) {
throw new PasskeyError(PasskeyErrorCode.CREDENTIAL_GET_FAILED, 'Failed to authenticate with passkey');
}
const response = credential.response;
return {
credentialId: toBase64(new Uint8Array(credential.rawId)),
signature: new Uint8Array(response.signature),
authenticatorData: new Uint8Array(response.authenticatorData),
clientDataJSON: new Uint8Array(response.clientDataJSON)
};
}
catch (error) {
if (error instanceof PasskeyError)
throw error;
const domError = error;
if (domError.name === 'NotAllowedError') {
throw new PasskeyError(PasskeyErrorCode.USER_CANCELLED, 'User cancelled the passkey authentication', domError);
}
if (domError.name === 'AbortError' || domError.name === 'TimeoutError') {
throw new PasskeyError(PasskeyErrorCode.TIMEOUT, 'Passkey authentication timed out', domError);
}
throw new PasskeyError(PasskeyErrorCode.CREDENTIAL_GET_FAILED, `Failed to authenticate: ${domError.message}`, domError);
}
}
/**
* Extract public key from attestation response
* Returns the raw secp256r1 public key bytes (65 bytes: 0x04 || x || y)
*/
extractPublicKey(response) {
// Check the algorithm used
const algorithm = response.getPublicKeyAlgorithm();
// console.log('[PasskeyService] Public key algorithm:', algorithm);
if (algorithm !== COSE_ALG_ES256) {
throw new PasskeyError(PasskeyErrorCode.CREDENTIAL_CREATE_FAILED, `Unsupported algorithm: ${algorithm}. Sui requires ES256 (P-256/secp256r1). Your authenticator may not support this algorithm.`);
}
const publicKey = response.getPublicKey();
if (!publicKey) {
throw new PasskeyError(PasskeyErrorCode.CREDENTIAL_CREATE_FAILED, 'Failed to extract public key from credential');
}
// The public key is in SPKI format, we need to extract the raw key
const spkiKey = new Uint8Array(publicKey);
// console.log('[PasskeyService] SPKI key length:', spkiKey.length);
// SPKI format for P-256 (91 bytes total):
// 30 59 - SEQUENCE (89 bytes)
// 30 13 - SEQUENCE (19 bytes) - algorithm identifier
// 06 07 2a 86 48 ce 3d 02 01 - OID ecPublicKey
// 06 08 2a 86 48 ce 3d 03 01 07 - OID prime256v1
// 03 42 00 - BIT STRING (66 bytes, 0 unused bits)
// 04 [32 bytes x] [32 bytes y] - uncompressed point
// For 91 bytes SPKI, the public key starts at offset 26 (after 0x00 padding)
// For 91 bytes: header(26) + 0x00(1) + key(64) = 91, so key is at offset 27
// But we need to add 0x04 prefix for uncompressed format
let rawPublicKey;
if (spkiKey.length === 91) {
// Standard P-256 SPKI format without 0x04 in the extracted portion
// The 0x04 is at offset 26, followed by 64 bytes of x,y coordinates
const keyStart = 26; // After the SPKI header
if (spkiKey[keyStart] === 0x04) {
// Key includes 0x04 prefix
rawPublicKey = spkiKey.slice(keyStart, keyStart + 65);
}
else if (spkiKey[keyStart] === 0x00 && spkiKey[keyStart + 1] === 0x04) {
// 0x00 padding before 0x04
rawPublicKey = spkiKey.slice(keyStart + 1, keyStart + 1 + 65);
}
else {
// Assume the last 64 bytes are x,y and prepend 0x04
const xyCoords = spkiKey.slice(-64);
rawPublicKey = new Uint8Array(65);
rawPublicKey[0] = 0x04;
rawPublicKey.set(xyCoords, 1);
}
}
else if (spkiKey.length === 65 && spkiKey[0] === 0x04) {
// Already in raw format
rawPublicKey = spkiKey;
}
else {
// Try to find 0x04 marker
let found = false;
for (let i = 0; i <= spkiKey.length - 65; i++) {
if (spkiKey[i] === 0x04) {
rawPublicKey = spkiKey.slice(i, i + 65);
found = true;
// console.log('[PasskeyService] Found 0x04 at offset:', i);
break;
}
}
if (!found) {
// Last resort: take last 64 bytes and prepend 0x04
if (spkiKey.length >= 64) {
const xyCoords = spkiKey.slice(-64);
rawPublicKey = new Uint8Array(65);
rawPublicKey[0] = 0x04;
rawPublicKey.set(xyCoords, 1);
// console.log('[PasskeyService] Using last 64 bytes with 0x04 prefix');
}
else {
throw new PasskeyError(PasskeyErrorCode.CREDENTIAL_CREATE_FAILED, `Invalid SPKI key length: ${spkiKey.length}`);
}
}
}
if (rawPublicKey[0] !== 0x04 || rawPublicKey.length !== 65) {
console.error('[PasskeyService] Invalid public key format:', {
firstByte: rawPublicKey[0],
length: rawPublicKey.length
});
throw new PasskeyError(PasskeyErrorCode.CREDENTIAL_CREATE_FAILED, `Invalid P-256 public key format`);
}
// console.log('[PasskeyService] Successfully extracted P-256 public key (65 bytes)');
return rawPublicKey;
}
/**
* Derive Sui address from secp256r1 public key
*/
async deriveSuiAddress(publicKey) {
// Import dynamically to avoid issues with SSR
const { Secp256r1PublicKey } = await import('@mysten/sui/keypairs/secp256r1');
// Create Secp256r1PublicKey from raw bytes
// The SDK expects the 33-byte compressed format or 65-byte uncompressed
const pubKey = new Secp256r1PublicKey(publicKey);
return pubKey.toSuiAddress();
}
/**
* Get the current configuration
*/
getConfig() {
return { ...this.config };
}
}