freelii-passkey-kit
Version:
A helper library for creating and using smart wallet accounts on the Stellar blockchain.
528 lines (527 loc) • 22.7 kB
JavaScript
import { Client as PasskeyClient } from 'passkey-kit-sdk';
import { StrKey, hash, xdr, Keypair, Address, TransactionBuilder, Operation } from '@stellar/stellar-sdk/minimal';
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
import { Buffer } from 'buffer';
import base64url from 'base64url';
import { PasskeyBase } from './base';
import { AssembledTransaction, basicNodeSigner } from '@stellar/stellar-sdk/minimal/contract';
export class PasskeyKit extends PasskeyBase {
walletKeypair;
walletPublicKey;
walletWasmHash;
timeoutInSeconds;
WebAuthn;
keyId;
networkPassphrase;
wallet;
constructor(options) {
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 };
}
async createWallet(app, user) {
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
};
}
async createKey(app, user, settings) {
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;
}
async connectWallet(opts) {
let { rpId, keyId, getContractId, walletPublicKey } = opts || {};
let keyIdBuffer;
let rawResponse;
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 = 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;
}
async signAuthEntry(entry, options) {
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;
let val;
// 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;
}
async sign(txn, options) {
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];
txn = await AssembledTransaction.buildWithOp(Operation.invokeHostFunction({ func: operation.func }), this.wallet.options);
}
}
}
await txn.signAuthEntries({
address: this.wallet.options.contractId,
authorizeEntry: (entry) => {
const clone = xdr.SorobanAuthorizationEntry.fromXDR(entry.toXDR());
return this.signAuthEntry(clone, options);
},
});
return txn;
}
addSecp256r1(keyId, publicKey, limits, store, expiration) {
return this.secp256r1(keyId, publicKey, limits, store, 'add_signer', expiration);
}
addEd25519(publicKey, limits, store, expiration) {
return this.ed25519(publicKey, limits, store, 'add_signer', expiration);
}
addPolicy(policy, limits, store, expiration) {
return this.policy(policy, limits, store, 'add_signer', expiration);
}
updateSecp256r1(keyId, publicKey, limits, store, expiration) {
return this.secp256r1(keyId, publicKey, limits, store, 'update_signer', expiration);
}
updateEd25519(publicKey, limits, store, expiration) {
return this.ed25519(publicKey, limits, store, 'update_signer', expiration);
}
updatePolicy(policy, limits, store, expiration) {
return this.policy(policy, limits, store, 'update_signer', expiration);
}
remove(signer) {
return this.wallet.remove_signer({
signer_key: this.getSignerKey(signer)
}, {
timeoutInSeconds: this.timeoutInSeconds,
});
}
secp256r1(keyId, publicKey, limits, store, fn, expiration) {
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,
});
}
ed25519(publicKey, limits, store, fn, expiration) {
return this.wallet[fn]({
signer: {
tag: "Ed25519",
values: [
Keypair.fromPublicKey(publicKey).rawPublicKey(),
[expiration],
this.getSignerLimits(limits),
{ tag: store, values: undefined },
],
},
}, {
timeoutInSeconds: this.timeoutInSeconds,
});
}
policy(policy, limits, store, fn, expiration) {
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
*/
getSignerLimits(limits) {
if (!limits)
return [undefined];
const sdk_limits = [new Map()];
for (const [contract, signer_keys] of limits.entries()) {
let sdk_signer_keys;
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;
}
getSignerKey({ key: tag, value }) {
let signer_key;
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;
}
encodeContract(walletPublicKey, keyIdBuffer) {
let contractId = 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;
}
async getPublicKey(response) {
let publicKey;
if (response.publicKey) {
publicKey = base64url.toBuffer(response.publicKey);
publicKey = publicKey?.slice(publicKey.length - 65);
}
if (!publicKey
|| publicKey[0] !== 0x04
|| publicKey.length !== 65) {
let x;
let y;
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;
}
compactSignature(signature) {
// 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;
}
}