npm.io
0.2.0 • Published 3d ago

stacks-passkey-wallet

Licence
MIT
Version
0.2.0
Deps
5
Size
96 kB
Vulns
0
Weekly
0

stacks-passkey-wallet

Pre-release — v0.1.0. Production hardening is in progress and the cross-device PRF support matrix is incomplete (see docs/prf-support-matrix.md). Adopters inherit these caveats: pin an exact version, and verify on your target passkey providers before relying on it for funds.

Passkey-derived wallet reference implementation for Stacks and Bitcoin — WebAuthn PRF → deterministic HD wallet. MIT licensed. Clean-room from the WebAuthn PRF, BIP-39, BIP-32/44/84 and RFC 5869 specifications.

One passkey, one biometric tap, and a user has a Stacks + Bitcoin wallet — no seed phrase to write down at signup, no browser extension. This repository is the open reference library; deorganized.com consumes it as a dependency.

How it works

passkey PRF output (32 bytes)
   │  HKDF-SHA256 (RFC 5869)
BIP-39 entropy (32 bytes) → 24-word mnemonic → BIP-32 seed
   ├─▶ m/44'/5757'/0'/0/0  → Stacks address   (SP…)
   └─▶ m/84'/0'/0'/0/0     → Bitcoin address  (bc1q…, native SegWit)

The same passkey + the same salt always derives the same wallet. The derivation paths are byte-identical to Leather's and Xverse's account-0 defaults (verified against their current sources). The salt, HKDF parameters, and locked test vectors are frozen together — see docs/wallet-identity.md.

Trust model — read this first

This is a hot wallet — the same security class as a browser-extension wallet. At signing time the private key is briefly in clear-text page memory; device malware at that moment could steal it. The passkey improves the experience (no seed at signup, phishing-resistant login) — it does not change the underlying trust model.

Appropriate for onboarding and casual/moderate balances. Not for treasuries.

The library never persists anything key-related: the seed is re-derived on demand, lives only in page memory, and is zeroized after use. Nothing touches localStorage, IndexedDB, cookies, or any server.

The #1 pitfall: a new passkey is a new, empty wallet

Two actions look similar and must never be conflated:

  • "Add a passkey" = login convenience. Another way to sign in to the same wallet, via a passkey synced by the same provider (iCloud Keychain / Google Password Manager).
  • "Restore my wallet" = funds recovery. Re-deriving your wallet on a new device from your exported seed phrase.

Creating a genuinely new passkey (a different provider, or an unsynced device) derives a different, empty wallet — not access to your original. Always back up the seed phrase for recovery. The library exposes WALLET_ACTIONS so host apps keep this language straight.

Supported platforms

Reliable, byte-identical PRF across a user's synced devices exists on two providers today:

Provider Status
iCloud Keychain (iOS/iPadOS 18.4+, macOS 15.4+) supported
Google Password Manager (Chrome/Edge 116+, desktop + Android) supported
Windows via Google Password Manager — Windows Hello is not supported for derivation (device-bound, would be single-device)
Firefox Android, Android WebView not supported

The iOS 18.4 floor is enforced as a hard runtime check (earlier versions can derive inconsistent bytes across devices). Full detail: docs/prf-support-matrix.md.

Install

npm install stacks-passkey-wallet

Usage

import {
  createPasskey,
  deriveAddressesFromPasskey,
  signStacksMessage,
  evaluatePrf,
  exportSeedPhrase,
} from "stacks-passkey-wallet";

// 1. Create a passkey with the PRF extension (throws PrfUnsupportedError if unavailable).
await createPasskey({
  rpName: "Your App",
  rpId: "yourapp.com",
  userId: crypto.getRandomValues(new Uint8Array(16)),
  userName: "alice",
  challenge: crypto.getRandomValues(new Uint8Array(32)),
});

// 2. Derive the wallet (fresh get() → PRF bytes → addresses).
const { stacks, bitcoin } = await deriveAddressesFromPasskey({
  challenge: crypto.getRandomValues(new Uint8Array(32)),
  rpId: "yourapp.com",
});
// stacks.address → "SP…", bitcoin.address → "bc1q…"

// 3. Sign — the library derives, signs, and discards the key internally.
const bytes = await evaluatePrf({ challenge: crypto.getRandomValues(new Uint8Array(32)), rpId: "yourapp.com" });
const { signature, publicKey } = signStacksMessage(bytes, "hello");
bytes.fill(0);

// 4. Export the seed for backup (gated + shown once).
const seed = await exportSeedPhrase({
  challenge: crypto.getRandomValues(new Uint8Array(32)),
  rpId: "yourapp.com",
  acknowledgedBackupWarning: true, // set only after the multi-step warning UI
});
console.log(seed.reveal()); // the 24-word phrase, once

See the integration guide for the full host-app flow and the demo for a runnable standalone example (npm run demo).

Development

npm install
npm run typecheck   # tsc --noEmit
npm test            # vitest — derivation vectors, signing, PRF, export
npm run build       # tsup → dist/ (ESM + types)
npm run gen:vectors # regenerate the frozen vectors (cross-validated)
npm run demo        # runnable browser demo (vite)

Test vectors are cross-validated against @stacks/wallet-sdk (Stacks) and bitcoinjs-lib (Bitcoin), plus a BIP-84 published golden anchor.

License & credits

MIT DeOrganized. Built as the open reference deliverable for a DeGrants Milestone 1. Contributions welcome under MIT.