- 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>
93 lines
3.5 KiB
JavaScript
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.");
|
|
}
|
|
}
|