UNPKG

freelii-passkey-kit

Version:

A helper library for creating and using smart wallet accounts on the Stellar blockchain.

695 lines (598 loc) 26.4 kB
import { Client as PasskeyClient, type Signature, type SignerKey as SDKSignerKey, type SignerLimits as SDKSignerLimits } from 'passkey-kit-sdk' import { StrKey, hash, xdr, Keypair, Address, TransactionBuilder, Operation } from '@stellar/stellar-sdk/minimal' import type { AuthenticationResponseJSON, AuthenticatorAttestationResponseJSON, AuthenticatorSelectionCriteria } from "@simplewebauthn/types" import { startRegistration, startAuthentication } from "@simplewebauthn/browser" import { Buffer } from 'buffer' import base64url from 'base64url' import type { SignerKey, SignerLimits, SignerStore } from './types' import { PasskeyBase } from './base' import { AssembledTransaction, basicNodeSigner, type AssembledTransactionOptions, type Tx } from '@stellar/stellar-sdk/minimal/contract' import type { Server } from '@stellar/stellar-sdk/minimal/rpc' export class PasskeyKit extends PasskeyBase { declare rpc: Server declare rpcUrl: string private walletKeypair: Keypair private walletPublicKey: string private walletWasmHash: string private timeoutInSeconds: number private WebAuthn: { startRegistration: typeof startRegistration, startAuthentication: typeof startAuthentication } public keyId: string | undefined public networkPassphrase: string public wallet: PasskeyClient | undefined constructor(options: { rpcUrl: string, networkPassphrase: string, walletWasmHash: string, timeoutInSeconds?: number, WebAuthn?: { startRegistration: typeof startRegistration, startAuthentication: typeof startAuthentication }, }) { const { rpcUrl, networkPassphrase, walletWasmHash, WebAuthn } = options super(rpcUrl) this.networkPassphrase = networkPassphrase // this account exists as the seed source for deploying new wallets // not using the genesis wallet as on mainnet the account has no usable signers // there's a chance this isn't the best move and should instead be a constructor variable // alternatively when we create a new wallet we shouldn't inherit the source as the auth entry signer // Keypair.fromRawEd25519Seed(hash(Buffer.from(this.networkPassphrase))) this.walletKeypair = Keypair.fromRawEd25519Seed(hash(Buffer.from('kalepail'))); this.walletPublicKey = this.walletKeypair.publicKey() this.walletWasmHash = walletWasmHash this.timeoutInSeconds = options.timeoutInSeconds || 30 // Launchtube requires <= 30 second timeout so let's default to that this.WebAuthn = WebAuthn || { startRegistration, startAuthentication } } public async createWallet(app: string, user: string) { const { rawResponse, keyId, keyIdBase64, publicKey } = await this.createKey(app, user) const at = await PasskeyClient.deploy( { signer: { tag: 'Secp256r1', values: [ keyId, publicKey, [undefined], [undefined], { tag: 'Persistent', values: undefined }, ] } }, { rpcUrl: this.rpcUrl, wasmHash: this.walletWasmHash, networkPassphrase: this.networkPassphrase, publicKey: this.walletPublicKey, salt: hash(keyId), timeoutInSeconds: this.timeoutInSeconds, } ) const contractId = at.result.options.contractId this.wallet = new PasskeyClient({ contractId, networkPassphrase: this.networkPassphrase, rpcUrl: this.rpcUrl }) await at.sign({ signTransaction: basicNodeSigner(this.walletKeypair, this.networkPassphrase).signTransaction }) return { rawResponse, keyId, keyIdBase64, contractId, signedTx: at.signed! } } public async createKey(app: string, user: string, settings?: { rpId?: string authenticatorSelection?: AuthenticatorSelectionCriteria }) { const now = new Date() const displayName = `${user} — ${now.toLocaleString()}` const { rpId, authenticatorSelection = { residentKey: "preferred", userVerification: "preferred", } } = settings || {} // TODO discover the contract id before creating the key so we can use it in the key name // TODO it's possible for the creation to fail in which case we've created a passkey but it's not onchain. // In this case we should save the passkey info and retry uploading it async vs asking the user to create another passkey // This does introduce a storage dependency though so it likely needs to be a function with some logic for choosing how to store the passkey data const rawResponse = await this.WebAuthn.startRegistration({ optionsJSON: { challenge: base64url("stellaristhebetterblockchain"), rp: { id: rpId, name: app, }, user: { id: base64url(`${user}:${now.getTime()}:${Math.random()}`), name: displayName, displayName }, authenticatorSelection, pubKeyCredParams: [{ alg: -7, type: "public-key" }], } }); const { id, response } = rawResponse if (!this.keyId) this.keyId = id; const result = { rawResponse, keyId: base64url.toBuffer(id), keyIdBase64: id, publicKey: await this.getPublicKey(response), } return result } public async connectWallet(opts?: { rpId?: string, keyId?: string | Uint8Array, getContractId?: (keyId: string) => Promise<string | undefined>, // TEMP for backwards compatibility for when we seeded wallets from a factory address // Consider putting this somewhere else?? walletPublicKey?: string }) { let { rpId, keyId, getContractId, walletPublicKey } = opts || {} let keyIdBuffer: Buffer let rawResponse: AuthenticationResponseJSON | undefined; if (!keyId) { rawResponse = await this.WebAuthn.startAuthentication({ optionsJSON: { challenge: base64url("stellaristhebetterblockchain"), rpId, userVerification: "preferred", } }); keyId = rawResponse.id } if (keyId instanceof Uint8Array) { keyIdBuffer = Buffer.from(keyId) keyId = base64url(keyIdBuffer) } else { keyIdBuffer = base64url.toBuffer(keyId) } if (!this.keyId) this.keyId = keyId // Check for the contractId on-chain as a derivation from the keyId. This is the easiest and "cheapest" check however it will only work for the initially deployed passkey if it was used as derivation let contractId: string | undefined = this.encodeContract(this.walletPublicKey, keyIdBuffer); // attempt passkey id derivation try { // TODO what is the error if the entry exists but is archived? await this.rpc.getContractData(contractId, xdr.ScVal.scvLedgerKeyContractInstance()) } // if that fails look up from the `getContractId` function catch (error) { contractId = getContractId && await getContractId(keyId) } //// // TEMP for backwards compatibility for when we seeded wallets from a factory address // Consider putting this in the constructor if (!contractId && walletPublicKey) { contractId = this.encodeContract(walletPublicKey, keyIdBuffer); try { await this.rpc.getContractData(contractId, xdr.ScVal.scvLedgerKeyContractInstance()) } catch (error) { contractId = undefined } } //// if (!contractId) { throw new Error('Failed to connect wallet') } this.wallet = new PasskeyClient({ contractId, rpcUrl: this.rpcUrl, networkPassphrase: this.networkPassphrase, }) const result = { rawResponse, keyId: keyIdBuffer, keyIdBase64: keyId, contractId } return result } public async signAuthEntry( entry: xdr.SorobanAuthorizationEntry, options?: { rpId?: string, keyId?: 'any' | string | Uint8Array keypair?: Keypair, policy?: string, expiration?: number } ) { let { rpId, keyId, keypair, policy, expiration } = options || {} if ([keyId, keypair, policy].filter((arg) => !!arg).length > 1) throw new Error('Exactly one of `options.keyId`, `options.keypair`, or `options.policy` must be provided.'); const credentials = entry.credentials().address(); if (!expiration) { expiration = credentials.signatureExpirationLedger() if (!expiration) { const { sequence } = await this.rpc.getLatestLedger() expiration = sequence + this.timeoutInSeconds / 5; // assumes 5 second ledger time } } credentials.signatureExpirationLedger(expiration) const preimage = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization( new xdr.HashIdPreimageSorobanAuthorization({ networkId: hash(Buffer.from(this.networkPassphrase)), nonce: credentials.nonce(), signatureExpirationLedger: credentials.signatureExpirationLedger(), invocation: entry.rootInvocation() }) ) const payload = hash(preimage.toXDR()) // let signatures: Signatures let key: SDKSignerKey let val: Signature | undefined // const scSpecTypeDefSignatures = xdr.ScSpecTypeDef.scSpecTypeUdt( // new xdr.ScSpecTypeUdt({ name: "Signatures" }), // ); // switch (credentials.signature().switch()) { // case xdr.ScValType.scvVoid(): // signatures = [new Map()] // break; // default: { // signatures = this.wallet!.spec.scValToNative(credentials.signature(), scSpecTypeDefSignatures) // } // } // Sign with a policy if (policy) { key = { tag: "Policy", values: [policy] } val = { tag: "Policy", values: undefined, } } // Sign with the keypair as an ed25519 signer else if (keypair) { const signature = keypair.sign(payload); key = { tag: "Ed25519", values: [keypair.rawPublicKey()] } val = { tag: "Ed25519", values: [signature], } } // Default, use passkey else { const authenticationResponse = await this.WebAuthn.startAuthentication({ optionsJSON: keyId === 'any' || (!keyId && !this.keyId) ? { challenge: base64url(payload), rpId, userVerification: "preferred", } : { challenge: base64url(payload), rpId, allowCredentials: [ { id: keyId instanceof Uint8Array ? base64url(Buffer.from(keyId)) : keyId || this.keyId!, type: "public-key", }, ], userVerification: "preferred", } }); key = { tag: "Secp256r1", values: [base64url.toBuffer(authenticationResponse.id)] } val = { tag: "Secp256r1", values: [ { authenticator_data: base64url.toBuffer( authenticationResponse.response.authenticatorData, ), client_data_json: base64url.toBuffer( authenticationResponse.response.clientDataJSON, ), signature: this.compactSignature( base64url.toBuffer(authenticationResponse.response.signature) ), }, ], } } const scKeyType = xdr.ScSpecTypeDef.scSpecTypeUdt( new xdr.ScSpecTypeUdt({ name: "SignerKey" }), ); const scValType = xdr.ScSpecTypeDef.scSpecTypeUdt( new xdr.ScSpecTypeUdt({ name: "Signature" }), ); const scKey = this.wallet!.spec.nativeToScVal(key, scKeyType); const scVal = val ? this.wallet!.spec.nativeToScVal(val, scValType) : xdr.ScVal.scvVoid(); const scEntry = new xdr.ScMapEntry({ key: scKey, val: scVal, }) switch (credentials.signature().switch().name) { case 'scvVoid': credentials.signature(xdr.ScVal.scvVec([ xdr.ScVal.scvMap([scEntry]) ])) break; case 'scvVec': // Add the new signature to the existing map credentials.signature().vec()?.[0].map()?.push(scEntry) // Order the map by key // Not using Buffer.compare because Symbols are 9 bytes and unused bytes _append_ 0s vs prepending them, which is too bad credentials.signature().vec()?.[0].map()?.sort((a, b) => { return ( a.key().vec()![0].sym() + a.key().vec()![1].toXDR().join('') ).localeCompare( b.key().vec()![0].sym() + b.key().vec()![1].toXDR().join('') ) }) break; default: throw new Error('Unsupported signature') } // Insert the new signature into the signatures Map // signatures[0].set(key, val) // Insert the new signatures Map into the credentials // credentials.signature( // this.wallet!.spec.nativeToScVal(signatures, scSpecTypeDefSignatures) // ) // Order the signatures map // credentials.signature().vec()?.[0].map()?.sort((a, b) => { // return ( // a.key().vec()![0].sym() + // a.key().vec()![1].toXDR().join('') // ).localeCompare( // b.key().vec()![0].sym() + // b.key().vec()![1].toXDR().join('') // ) // }) return entry } public async sign<T>( txn: AssembledTransaction<T> | Tx | string, options?: { rpId?: string, keyId?: 'any' | string | Uint8Array keypair?: Keypair, policy?: string, expiration?: number } ) { if (!(txn instanceof AssembledTransaction)) { try { txn = AssembledTransaction.fromXDR(this.wallet!.options, typeof txn === 'string' ? txn : txn.toXDR(), this.wallet!.spec) } catch (error) { if (!(txn instanceof AssembledTransaction)) { const built = TransactionBuilder.fromXDR(typeof txn === 'string' ? txn : txn.toXDR(), this.networkPassphrase); const operation = built.operations[0] as Operation.InvokeHostFunction; txn = await AssembledTransaction.buildWithOp<T>( Operation.invokeHostFunction({ func: operation.func }), this.wallet!.options as AssembledTransactionOptions<T> ); } } } await txn.signAuthEntries({ address: this.wallet!.options.contractId, authorizeEntry: (entry) => { const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR()) return this.signAuthEntry(clone, options) }, }) return txn } public addSecp256r1(keyId: string | Uint8Array, publicKey: string | Uint8Array, limits: SignerLimits, store: SignerStore, expiration?: number) { return this.secp256r1(keyId, publicKey, limits, store, 'add_signer', expiration) } public addEd25519(publicKey: string, limits: SignerLimits, store: SignerStore, expiration?: number) { return this.ed25519(publicKey, limits, store, 'add_signer', expiration) } public addPolicy(policy: string, limits: SignerLimits, store: SignerStore, expiration?: number) { return this.policy(policy, limits, store, 'add_signer', expiration) } public updateSecp256r1(keyId: string | Uint8Array, publicKey: string | Uint8Array, limits: SignerLimits, store: SignerStore, expiration?: number) { return this.secp256r1(keyId, publicKey, limits, store, 'update_signer', expiration) } public updateEd25519(publicKey: string, limits: SignerLimits, store: SignerStore, expiration?: number) { return this.ed25519(publicKey, limits, store, 'update_signer', expiration) } public updatePolicy(policy: string, limits: SignerLimits, store: SignerStore, expiration?: number) { return this.policy(policy, limits, store, 'update_signer', expiration) } public remove(signer: SignerKey) { return this.wallet!.remove_signer({ signer_key: this.getSignerKey(signer) }, { timeoutInSeconds: this.timeoutInSeconds, }); } private secp256r1(keyId: string | Uint8Array, publicKey: string | Uint8Array, limits: SignerLimits, store: SignerStore, fn: 'add_signer' | 'update_signer', expiration?: number) { keyId = typeof keyId === 'string' ? base64url.toBuffer(keyId) : keyId publicKey = typeof publicKey === 'string' ? base64url.toBuffer(publicKey) : publicKey return this.wallet![fn]({ signer: { tag: "Secp256r1", values: [ Buffer.from(keyId), Buffer.from(publicKey), [expiration], this.getSignerLimits(limits), { tag: store, values: undefined }, ], }, }, { timeoutInSeconds: this.timeoutInSeconds, }); } private ed25519(publicKey: string, limits: SignerLimits, store: SignerStore, fn: 'add_signer' | 'update_signer', expiration?: number) { return this.wallet![fn]({ signer: { tag: "Ed25519", values: [ Keypair.fromPublicKey(publicKey).rawPublicKey(), [expiration], this.getSignerLimits(limits), { tag: store, values: undefined }, ], }, }, { timeoutInSeconds: this.timeoutInSeconds, }); } private policy(policy: string, limits: SignerLimits, store: SignerStore, fn: 'add_signer' | 'update_signer', expiration?: number) { return this.wallet![fn]({ signer: { tag: "Policy", values: [ policy, [expiration], this.getSignerLimits(limits), { tag: store, values: undefined }, ], }, }, { timeoutInSeconds: this.timeoutInSeconds, }); } /* LATER - Add a getKeyInfo action to get info about a specific passkey Specifically looking for name, type, etc. data so a user could grok what signer mapped to what passkey */ private getSignerLimits(limits: SignerLimits) { if (!limits) return [undefined] as SDKSignerLimits const sdk_limits: SDKSignerLimits = [new Map()] for (const [contract, signer_keys] of limits.entries()) { let sdk_signer_keys: SDKSignerKey[] | undefined if (signer_keys?.length) { sdk_signer_keys = [] for (const signer_key of signer_keys) { sdk_signer_keys.push( this.getSignerKey(signer_key) ) } } sdk_limits[0]?.set(contract, sdk_signer_keys) } return sdk_limits } private getSignerKey({ key: tag, value }: SignerKey) { let signer_key: SDKSignerKey switch (tag) { case 'Policy': signer_key = { tag, values: [value] } break; case 'Ed25519': signer_key = { tag, values: [Keypair.fromPublicKey(value).rawPublicKey()] } break; case 'Secp256r1': signer_key = { tag, values: [base64url.toBuffer(value)] } break; } return signer_key } private encodeContract(walletPublicKey: string, keyIdBuffer: Buffer) { let contractId: string | undefined = StrKey.encodeContract(hash(xdr.HashIdPreimage.envelopeTypeContractId( new xdr.HashIdPreimageContractId({ networkId: hash(Buffer.from(this.networkPassphrase)), contractIdPreimage: xdr.ContractIdPreimage.contractIdPreimageFromAddress( new xdr.ContractIdPreimageFromAddress({ address: Address.fromString(walletPublicKey).toScAddress(), salt: hash(keyIdBuffer), }) ) }) ).toXDR())); return contractId } private async getPublicKey(response: AuthenticatorAttestationResponseJSON) { let publicKey: Buffer | undefined if (response.publicKey) { publicKey = base64url.toBuffer(response.publicKey) publicKey = publicKey?.slice(publicKey.length - 65) } if ( !publicKey || publicKey[0] !== 0x04 || publicKey.length !== 65 ) { let x: Buffer let y: Buffer if (response.authenticatorData) { const authenticatorData = base64url.toBuffer(response.authenticatorData) const credentialIdLength = (authenticatorData[53] << 8) | authenticatorData[54] x = authenticatorData.slice(65 + credentialIdLength, 97 + credentialIdLength) y = authenticatorData.slice(100 + credentialIdLength, 132 + credentialIdLength) } else { const attestationObject = base64url.toBuffer(response.attestationObject) let publicKeykPrefixSlice = Buffer.from([0xa5, 0x01, 0x02, 0x03, 0x26, 0x20, 0x01, 0x21, 0x58, 0x20]) let startIndex = attestationObject.indexOf(publicKeykPrefixSlice) startIndex = startIndex + publicKeykPrefixSlice.length x = attestationObject.slice(startIndex, 32 + startIndex) y = attestationObject.slice(35 + startIndex, 67 + startIndex) } publicKey = Buffer.from([ 0x04, // (0x04 prefix) https://en.bitcoin.it/wiki/Elliptic_Curve_Digital_Signature_Algorithm ...x, ...y ]) } /* TODO - We're doing some pretty "smart" public key decoding stuff so we should verify the signature against this final public key before assuming it's safe to use and save on-chain Hmm...Given that `startRegistration` doesn't produce a signature, verifying we've got the correct public key isn't really possible - This probably needs to be an onchain check, even if just a simulation, just to ensure everything looks good before we get too far adding value etc. */ return publicKey } private compactSignature(signature: Buffer) { // Decode the DER signature let offset = 2; const rLength = signature[offset + 1]; const r = signature.slice(offset + 2, offset + 2 + rLength); offset += 2 + rLength; const sLength = signature[offset + 1]; const s = signature.slice(offset + 2, offset + 2 + sLength); // Convert r and s to BigInt const rBigInt = BigInt('0x' + r.toString('hex')); let sBigInt = BigInt('0x' + s.toString('hex')); // Ensure s is in the low-S form // https://github.com/stellar/stellar-protocol/discussions/1435#discussioncomment-8809175 // https://discord.com/channels/897514728459468821/1233048618571927693 // Define the order of the curve secp256r1 // https://github.com/RustCrypto/elliptic-curves/blob/master/p256/src/lib.rs#L72 const n = BigInt('0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551'); const halfN = n / 2n; if (sBigInt > halfN) sBigInt = n - sBigInt; // Convert back to buffers and ensure they are 32 bytes const rPadded = Buffer.from(rBigInt.toString(16).padStart(64, '0'), 'hex'); const sLowS = Buffer.from(sBigInt.toString(16).padStart(64, '0'), 'hex'); // Concatenate r and low-s const concatSignature = Buffer.concat([rPadded, sLowS]); return concatSignature; } }