Googstr/public/passkey.js
Jason Tudisco fcfaec675a Add NIP-46 signer, optional NIP-07 extension, passkey protection, backups
- NIP-46 remote signer in the page (bunker:// and nostrconnect:// pairing)
- Chrome extension (NIP-07) with per-site permission prompts, unlocked by
  the site via externally_connectable; downloadable from the page
- Passkey (WebAuthn PRF) encryption of the Drive key file
- NIP-49 backups: download or save to Drive; reveal nsec
- Fix: oldest key file wins when two devices sign up at once
- Fix: never overwrite an existing kind-0 because a relay was slow
- Fix: EOSE tracking; relays reconnect with backoff; verify event signatures
- One-click re-sign-in (consent only the first time)
- Vendored, pinned nostr-tools; CSP; no third-party scripts but Google's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 08:33:02 -06:00

93 lines
3.5 KiB
JavaScript

// Passkey-based key wrapping via the WebAuthn PRF extension.
//
// A passkey with PRF returns a stable 32-byte secret for a given salt, but only
// after the user verifies (Face ID, fingerprint, device PIN). We turn that into
// an AES-GCM key with HKDF and use it to encrypt the nostr key before it goes
// to Drive. Result: Google access alone is no longer enough to read the key.
//
// Passkeys are bound to the site's domain (rpId). A key protected on
// goostr.example.com can only be unlocked on goostr.example.com.
const enc = new TextEncoder();
const rand = (n) => crypto.getRandomValues(new Uint8Array(n));
export const passkeysAvailable = () => !!window.PublicKeyCredential;
async function toAesKey(prfOutput) {
const ikm = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveKey"]);
return crypto.subtle.deriveKey(
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: enc.encode("goostr key-wrap v1") },
ikm,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"]
);
}
function notSupported() {
return new Error(
"This passkey provider can't do key encryption (the WebAuthn PRF extension). " +
"Use a recent Chrome, Edge or Safari with Google Password Manager, iCloud Keychain or a compatible security key."
);
}
// Create a new passkey and return { credId, salt, aesKey }. Asks for the PRF
// output during creation where supported; otherwise follows up with get().
export async function createPasskeyKey({ userId, name, displayName }) {
const salt = rand(32);
const cred = await navigator.credentials.create({
publicKey: {
rp: { name: "Goostr" },
user: { id: userId, name, displayName },
challenge: rand(32),
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 }, // RS256
],
authenticatorSelection: { residentKey: "preferred", userVerification: "required" },
extensions: { prf: { eval: { first: salt } } },
},
});
const ext = cred.getClientExtensionResults().prf;
if (ext?.enabled === false) throw notSupported();
const credId = new Uint8Array(cred.rawId);
const first = ext?.results?.first;
const aesKey = first ? await toAesKey(first) : await getPasskeyKey({ credId, salt });
return { credId, salt, aesKey };
}
// Ask the user to verify with an existing passkey and derive the AES key.
export async function getPasskeyKey({ credId, salt }) {
const assertion = await navigator.credentials.get({
publicKey: {
challenge: rand(32),
allowCredentials: [{ type: "public-key", id: credId }],
userVerification: "required",
extensions: { prf: { eval: { first: salt } } },
},
});
const first = assertion.getClientExtensionResults().prf?.results?.first;
if (!first) throw notSupported();
return toAesKey(first);
}
// AES-GCM with the pubkey as associated data, so a ciphertext can't be
// swapped between identities unnoticed.
export async function wrapKey(aesKey, keyBytes, pubkeyHex) {
const iv = rand(12);
const ct = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: enc.encode(pubkeyHex) }, aesKey, keyBytes)
);
return { iv, ct };
}
export async function unwrapKey(aesKey, { iv, ct }, pubkeyHex) {
try {
return new Uint8Array(
await crypto.subtle.decrypt({ name: "AES-GCM", iv, additionalData: enc.encode(pubkeyHex) }, aesKey, ct)
);
} catch {
throw new Error("That passkey doesn't unlock this key. Use the passkey you protected it with.");
}
}