diff --git a/typescript/packages/common-identity/src/pass-key.ts b/typescript/packages/common-identity/src/pass-key.ts index 5c82db47b..dde70e0b3 100644 --- a/typescript/packages/common-identity/src/pass-key.ts +++ b/typescript/packages/common-identity/src/pass-key.ts @@ -15,11 +15,12 @@ const ALGS: PublicKeyCredentialParameters[] = [ { type: "public-key", alg: -8 }, // ed25519 { type: "public-key", alg: -7 }, // es256 { type: "public-key", alg: -257 }, // rs256 -] +]; export interface PassKeyGetOptions { - mediation?: "conditional", - userVerification?: "required" | "preferred" | "discouraged" + mediation?: "conditional"; + userVerification?: "required" | "preferred" | "discouraged"; + allowCredentials?: PublicKeyCredentialDescriptor[]; } // A `PassKey` represents an authentication via a WebAuthn authenticator. @@ -31,13 +32,20 @@ export class PassKey { this.credentials = credentials; } + id() { + return this.credentials.id; + } + // Generate a root key from a `PassKey`. // A root key identity is deterministically derived from a `PassKey`'s // PRF output, a 32-byte hash, which is used as ed25519 key material. + // Note: Root keys can only be created from PassKeys obtained via PassKey.get() async createRootKey(): Promise { let seed = this.prf(); if (!seed) { - throw new Error("common-identity: No prf found from PassKey"); + throw new Error( + "common-identity: No PRF found. This PassKey appears to have just been created - root keys can only be generated from PassKeys obtained via PassKey.get()", + ); } return await Identity.fromRaw(seed); @@ -66,7 +74,7 @@ export class PassKey { // Different data is available within `PublicKeyCredentials` depending // on whether it was created or retrieved. We need the PRF assertion // only available on "get" requests, so we don't return a `PassKey` here. - static async create(name: string, displayName: string): Promise { + static async create(name: string, displayName: string): Promise { const challenge = random(32); const userId = random(32); const user = { @@ -74,7 +82,7 @@ export class PassKey { name, displayName, }; - + let publicKey: PublicKeyCredentialCreationOptions = { challenge, rp: { id: RP_ID, name: RP }, @@ -93,10 +101,10 @@ export class PassKey { userVerification: "preferred", // default }, pubKeyCredParams: ALGS, - extensions: { prf: { eval: { first: PRF_SALT }}}, + extensions: { prf: { eval: { first: PRF_SALT } } }, timeout: TIMEOUT, }; - + let result = (await navigator.credentials.create({ publicKey })) as PublicKeyCredential | null; if (!result) { throw new Error("common-identity: Could not create passkey"); @@ -105,23 +113,29 @@ export class PassKey { if (!extResults?.prf?.enabled) { throw new Error("common-identity: prf extension not supported."); } + + return new PassKey(result); } // Retrieve a `PassKey` from a Web Authn authenticator. // In browsers, must be called via a user gesture. - static async get({ userVerification, mediation }: PassKeyGetOptions = {}): Promise { + static async get({ + userVerification, + mediation, + allowCredentials = [], + }: PassKeyGetOptions = {}): Promise { // Select any credential available with the same `RP_ID`. - let credential = await navigator.credentials.get({ + let credential = (await navigator.credentials.get({ publicKey: { - allowCredentials: [], + allowCredentials, challenge: random(32), rpId: RP_ID, userVerification: userVerification ?? "preferred", - extensions: { prf: { eval: { first: PRF_SALT }}}, + extensions: { prf: { eval: { first: PRF_SALT } } }, timeout: TIMEOUT, }, mediation, - }) as PublicKeyCredential | null; + })) as PublicKeyCredential | null; if (!credential) { throw new Error("common-identity: Could not create credentials."); @@ -136,7 +150,7 @@ export class PassKey { } return new PassKey(credential); } - + private getCredentials(): PublicKeyCredential { return this.credentials; } diff --git a/typescript/packages/jumble/src/contexts/AuthenticationContext.tsx b/typescript/packages/jumble/src/contexts/AuthenticationContext.tsx index 140893043..8d0fd3025 100644 --- a/typescript/packages/jumble/src/contexts/AuthenticationContext.tsx +++ b/typescript/packages/jumble/src/contexts/AuthenticationContext.tsx @@ -10,9 +10,9 @@ interface AuthenticationContextType { // The authenticated user/persona. user: Identity | void; // Call PassKey registration. - passkeyRegister: (name: string, displayName: string) => Promise; + passkeyRegister: (name: string, displayName: string) => Promise; // Authenticate the user via passkey. - passkeyAuthenticate: () => Promise; + passkeyAuthenticate: (descriptor?: PublicKeyCredentialDescriptor) => Promise; // Generate a passphrase for a new user passphraseRegister: () => Promise; // Authenticate via passphrase. @@ -21,7 +21,7 @@ interface AuthenticationContextType { clearAuthentication: () => Promise; // Internal: Root key. root: Identity | void; - // Internal: Persistent storage for keys. + // Internal: Persistent storage for keys. keyStore: KeyStore | void; } @@ -36,19 +36,19 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = ( useEffect(() => { let ignore = false; async function getKeyStoreAndRoot() { - let keyStore = await KeyStore.open(); - let root = await keyStore.get(ROOT_KEY); + const keyStore = await KeyStore.open(); + const root = await keyStore.get(ROOT_KEY); if (!ignore) { setKeyStore(keyStore); setRoot(root); } } - getKeyStoreAndRoot(); + getKeyStoreAndRoot(); return () => { ignore = true; setKeyStore(undefined); setRoot(undefined); - } + }; }, []); // When root changes, update `user` to the default persona @@ -59,7 +59,7 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = ( if (!root) { return; } - let user = await root.derive(DEFAULT_PERSONA); + const user = await root.derive(DEFAULT_PERSONA); if (!ignore) { setUser(user); } @@ -68,7 +68,7 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = ( return () => { ignore = true; setUser(undefined); - } + }; }, [root]); // This calls out to WebAuthn to register a user. The state of whether @@ -77,44 +77,51 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = ( // // Must be called within a user gesture. const passkeyRegister = useCallback(async (name: string, displayName: string) => { - return PassKey.create(name, displayName); + const credential = await PassKey.create(name, displayName); + return credential; }, []); - // This should be called when a passkey (possibly) exists for the user already, // and no root key has yet been stored (e.g. first login). Subsequent page loads // should load key from storage and not require this callback. // // Must be called within a user gesture. - const passkeyAuthenticate = useCallback(async () => { - if (!keyStore) { - return; - } - let passkey = await PassKey.get(); - let root = await passkey.createRootKey(); - await keyStore.set(ROOT_KEY, root); - setRoot(root); - }, [keyStore]); - + const passkeyAuthenticate = useCallback( + async (key?: PublicKeyCredentialDescriptor) => { + if (!keyStore) { + throw new Error("Key store not initialized"); + } + // if we can, we prompt directly for the passed credential + const passkey = await PassKey.get({ allowCredentials: key ? [key] : [] }); + const root = await passkey.createRootKey(); + await keyStore.set(ROOT_KEY, root); + setRoot(root); + return passkey; + }, + [keyStore], + ); const passphraseRegister = useCallback(async () => { // Don't store the root identity here. Return only the // mnemonic so that the UI can present guidance on handling // the private key. The root will be derived from the mnemonic // on authentication. - let [_, mnemonic] = await Identity.generateMnemonic(); + const [, mnemonic] = await Identity.generateMnemonic(); return mnemonic; }, []); - const passphraseAuthenticate = useCallback(async (mnemonic: string) => { - if (!keyStore) { - return; - } - let root = await Identity.fromMnemonic(mnemonic); - await keyStore.set(ROOT_KEY, root); - setRoot(root); - }, [keyStore]); - + const passphraseAuthenticate = useCallback( + async (mnemonic: string) => { + if (!keyStore) { + throw new Error("Key store not initialized"); + } + const root = await Identity.fromMnemonic(mnemonic); + await keyStore.set(ROOT_KEY, root); + setRoot(root); + }, + [keyStore], + ); + const clearAuthentication = useCallback(async () => { if (!keyStore) { return; @@ -125,16 +132,18 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = ( }, [keyStore]); return ( - + {children} ); diff --git a/typescript/packages/jumble/src/utils/credentials.ts b/typescript/packages/jumble/src/utils/credentials.ts new file mode 100644 index 000000000..9aea85264 --- /dev/null +++ b/typescript/packages/jumble/src/utils/credentials.ts @@ -0,0 +1,48 @@ +export const AUTH_METHOD_PASSKEY = "passkey" as const; +export const AUTH_METHOD_PASSPHRASE = "passphrase" as const; + +export type AuthMethod = typeof AUTH_METHOD_PASSKEY | typeof AUTH_METHOD_PASSPHRASE; + +export interface StoredCredential { + id: string; + method: AuthMethod; +} + +export function getStoredCredential(): StoredCredential | null { + const stored = localStorage.getItem("storedCredential"); + return stored ? JSON.parse(stored) : null; +} + +export function saveCredential(credential: StoredCredential): void { + localStorage.setItem("storedCredential", JSON.stringify(credential)); +} + +export function clearStoredCredential(): void { + localStorage.removeItem("storedCredential"); +} + +export function createPasskeyCredential(id: string): StoredCredential { + return { + id, + method: "passkey", + }; +} + +export function createPassphraseCredential(): StoredCredential { + return { + id: crypto.randomUUID(), + method: "passphrase", + }; +} + +export function getPublicKeyCredentialDescriptor( + storedCredential: StoredCredential | null, +): PublicKeyCredentialDescriptor | undefined { + if (storedCredential?.method === "passkey") { + return { + id: Uint8Array.from(atob(storedCredential.id), (c) => c.charCodeAt(0)), + type: "public-key" as PublicKeyCredentialType, + }; + } + return undefined; +} diff --git a/typescript/packages/jumble/src/views/AuthenticationView.tsx b/typescript/packages/jumble/src/views/AuthenticationView.tsx index 2c9d694f5..7e17d12e8 100644 --- a/typescript/packages/jumble/src/views/AuthenticationView.tsx +++ b/typescript/packages/jumble/src/views/AuthenticationView.tsx @@ -1,62 +1,366 @@ import { useAuthentication } from "@/contexts/AuthenticationContext.tsx"; -import { useCallback, useRef, useState } from "react"; +import ShapeLogo from "@/assets/ShapeLogo.svg"; +import { useCallback, useEffect, useState } from "react"; +import { + LuArrowLeft, + LuKey, + LuKeyRound, + LuCirclePlus, + LuLock, + LuTextCursorInput, + LuCopy, + LuTrash2, + LuCheck, +} from "react-icons/lu"; +import { + type StoredCredential, + getStoredCredential, + saveCredential, + clearStoredCredential, + createPasskeyCredential, + createPassphraseCredential, + getPublicKeyCredentialDescriptor, + AuthMethod, + AUTH_METHOD_PASSKEY, + AUTH_METHOD_PASSPHRASE, +} from "@/utils/credentials"; -const BTN_STYLE=`bg-gray-50 border-2 p-2 w-full flex-1 cursor-pointer`; -const PW_STYLE=`bg-gray-50 border-2 p-2 w-full flex-1`; +const BTN_PRIMARY = `w-full px-4 py-2 bg-black text-white hover:bg-gray-800 disabled:opacity-50 flex items-center justify-center gap-2`; +const LIST_ITEM = `w-full p-2 text-left text-sm border-2 border-black hover:-translate-y-[2px] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,0.5)] shadow-[1px_1px_0px_0px_rgba(0,0,0,0.3)] transition-all duration-100 ease-in-out cursor-pointer flex items-center gap-2`; + +type AuthFlow = "register" | "login"; + +interface ErrorCalloutProps { + error: string; + onDismiss: () => void; +} + +interface SuccessRegistrationProps { + mnemonic?: string; + onLogin: () => void; + method: AuthMethod; + credentialId?: string; +} + +function ErrorCallout({ error, onDismiss }: ErrorCalloutProps) { + return ( +
+
+
{error}
+ +
+
+ ); +} + +function SuccessRegistration({ + mnemonic, + onLogin, + method, + credentialId, +}: SuccessRegistrationProps) { + const [copied, setCopied] = useState(false); + + const copyToClipboard = () => { + if (mnemonic) { + navigator.clipboard.writeText(mnemonic); + setCopied(true); + setTimeout(() => setCopied(false), 750); + } + }; + + return ( +
+ {method === AUTH_METHOD_PASSKEY ? ( +
+

Passkey successfully registered!

+ {credentialId && ( +

Key ID: ...{credentialId.slice(-4)}

+ )} +
+ ) : ( + mnemonic && ( +
+

Your Secret Recovery Phrase:

+
+ - -
-
- ) + const handleAuth = useCallback(async (action: () => Promise) => { + try { + setError(null); + setIsProcessing(true); + return await action(); + } catch (e) { + setError(e instanceof Error ? e.message : "Authentication failed"); + // Reset both flow and method to return to initial state + setFlow(null); + setMethod(null); + } finally { + setIsProcessing(false); + } + }, []); + + const handleRegister = useCallback( + async (selectedMethod: string) => { + if (selectedMethod === AUTH_METHOD_PASSKEY) { + const credential = await handleAuth(() => + auth.passkeyRegister("Common Tools User", "commontoolsuser"), + ); + if (!credential) throw new Error("Credential not found"); + setMethod(AUTH_METHOD_PASSKEY); + setRegistrationSuccess(true); + } else { + const mnemonic = await auth.passphraseRegister(); + setMnemonic(mnemonic); + } + }, + [handleAuth, auth], + ); + + const handleLogin = useCallback( + async (selectedMethod: string, passphrase?: string) => { + if (selectedMethod === AUTH_METHOD_PASSKEY) { + const credentialDescriptor = getPublicKeyCredentialDescriptor(storedCredential); + + await handleAuth(async () => { + setMethod(AUTH_METHOD_PASSKEY); + setFlow("login"); + const passkey = await auth.passkeyAuthenticate(credentialDescriptor); + + // Store credentials before completing authentication + if (!storedCredential) { + const storedCred = createPasskeyCredential(passkey.id()); + saveCredential(storedCred); + setStoredCredential(storedCred); + } + + return passkey; + }); + } else if (passphrase) { + await handleAuth(async () => { + await auth.passphraseAuthenticate(passphrase); + + if (!storedCredential) { + const storedCred = createPassphraseCredential(); + saveCredential(storedCred); + setStoredCredential(storedCred); + } + }); + } + }, + [storedCredential, handleAuth, auth], + ); + + const handleMethodSelect = useCallback( + async (selectedMethod: AuthMethod) => { + setMethod(selectedMethod); + if (flow === "register") { + await handleRegister(selectedMethod); + } else if (flow === "login") { + if (selectedMethod === AUTH_METHOD_PASSKEY) { + await handleLogin(selectedMethod); // This login already handles credential storage + } + // For passphrase, we'll wait for the form submission + } + }, + [flow, handleRegister, handleLogin], + ); + + if (auth.user) { + throw new Error("Already authenticated"); } + return ( -
-
-

via Passkey

- - +
+
+
-
-

via Passphrase

- - - +
+ {error && setError(null)} />} + + {isProcessing ? ( +
+

Please follow the browser's prompts to continue...

+
+ ) : mnemonic || registrationSuccess ? ( + { + if (method === AUTH_METHOD_PASSKEY) { + await handleLogin(AUTH_METHOD_PASSKEY); + } else { + setMnemonic(null); + setRegistrationSuccess(false); + setFlow("login"); + } + }} + /> + ) : flow === null ? ( +
+ {storedCredential ? ( + <> + + + + + + ) : ( + <> + + + + )} +
+ ) : flow !== null && method === null ? ( +
+

{flow === "login" ? "Login with" : "Register with"}

+ {availableMethods.map((m) => ( + + ))} + +
+ ) : method === AUTH_METHOD_PASSPHRASE ? ( +
+ {flow === "register" ? ( + + ) : ( +
{ + e.preventDefault(); + const form = e.target as HTMLFormElement; + handleLogin(method, new FormData(form).get(AUTH_METHOD_PASSPHRASE) as string); + }} + > + + +
+ )} + +
+ ) : null}
);