From f9d96c96950ac7eb671b8b7620e5cdb33c9e5dcd Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Tue, 25 Feb 2025 11:57:37 +1000 Subject: [PATCH 1/9] Rework auth flow --- .../packages/common-identity/src/pass-key.ts | 33 +- .../src/contexts/AuthenticationContext.tsx | 90 ++--- .../jumble/src/views/AuthenticationView.tsx | 344 +++++++++++++++--- 3 files changed, 367 insertions(+), 100 deletions(-) diff --git a/typescript/packages/common-identity/src/pass-key.ts b/typescript/packages/common-identity/src/pass-key.ts index 5c82db47b..20e77249c 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. @@ -66,7 +67,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 +75,7 @@ export class PassKey { name, displayName, }; - + let publicKey: PublicKeyCredentialCreationOptions = { challenge, rp: { id: RP_ID, name: RP }, @@ -93,10 +94,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 +106,29 @@ export class PassKey { if (!extResults?.prf?.enabled) { throw new Error("common-identity: prf extension not supported."); } + + return 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 +143,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..5d4ccd4e4 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,50 @@ 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) { + return; + } + // Pass the keyName to PassKey.get() if provided + const passkey = await PassKey.get({ allowCredentials: key ? [key] : [] }); + const root = await passkey.createRootKey(); + await keyStore.set(ROOT_KEY, root); + setRoot(root); + }, + [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) { + return; + } + const root = await Identity.fromMnemonic(mnemonic); + await keyStore.set(ROOT_KEY, root); + setRoot(root); + }, + [keyStore], + ); + const clearAuthentication = useCallback(async () => { if (!keyStore) { return; @@ -125,16 +131,18 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = ( }, [keyStore]); return ( - + {children} ); diff --git a/typescript/packages/jumble/src/views/AuthenticationView.tsx b/typescript/packages/jumble/src/views/AuthenticationView.tsx index 2c9d694f5..75744ddef 100644 --- a/typescript/packages/jumble/src/views/AuthenticationView.tsx +++ b/typescript/packages/jumble/src/views/AuthenticationView.tsx @@ -1,62 +1,314 @@ import { useAuthentication } from "@/contexts/AuthenticationContext.tsx"; import { useCallback, useRef, useState } from "react"; +import ShapeLogo from "@/assets/ShapeLogo.svg"; +import { useAuthentication } from "@/contexts/AuthenticationContext"; +import { useCallback, useEffect, useState } from "react"; +import { + LuArrowLeft, + LuKey, + LuKeyRound, + LuCirclePlus, + LuLock, + LuTextCursorInput, + LuCopy, + LuTrash2, + LuCheck, +} from "react-icons/lu"; -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`; +const INPUT_STYLE = `w-full p-2 border rounded`; + +type AuthMethod = "passkey" | "passphrase"; +type AuthFlow = "register" | "login"; + +interface ErrorCalloutProps { + error: string; + onDismiss: () => void; +} + +interface SuccessRegistrationProps { + mnemonic?: string; + onLogin: () => void; + method: AuthMethod; +} + +interface StoredCredential { + id: string; + type: "public-key" | "passphrase"; + method: AuthMethod; +} + +function ErrorCallout({ error, onDismiss }: ErrorCalloutProps) { + return ( +
+
+
{error}
+ +
+
+ ); +} +function SuccessRegistration({ mnemonic, onLogin, method }: SuccessRegistrationProps) { + const [copied, setCopied] = useState(false); + + const copyToClipboard = () => { + if (mnemonic) { + navigator.clipboard.writeText(mnemonic); + setCopied(true); + setTimeout(() => setCopied(false), 750); + } + }; + + return ( +
+ {mnemonic && ( +
+

Your Secret Recovery Phrase:

+
+ - -
-
- ) + if (auth.user) { + throw new Error("Already authenticated"); } return ( -
-
-

via Passkey

- - +
+
+
-
-

via Passphrase

- - - +
+ {error && setError(null)} />} + + {mnemonic ? ( + { + setMnemonic(null); + setFlow("login"); + }} + /> + ) : flow === null ? ( +
+ {storedCredential ? ( + <> + + + + + + ) : ( + <> + + + + )} +
+ ) : flow !== null && method === null ? ( +
+

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

+ {availableMethods.map((m) => ( + + ))} + +
+ ) : method === "passphrase" ? ( +
+ {flow === "register" ? ( + + ) : ( +
{ + e.preventDefault(); + const form = e.target as HTMLFormElement; + handleLogin(method, new FormData(form).get("passphrase") as string); + }} + > + + +
+ )} + +
+ ) : null}
); From edf2aef848d381cdd41f62cbeb72712817b8abc9 Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Tue, 25 Feb 2025 12:31:24 +1000 Subject: [PATCH 2/9] Expose id from PassKey class --- typescript/packages/common-identity/src/pass-key.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/typescript/packages/common-identity/src/pass-key.ts b/typescript/packages/common-identity/src/pass-key.ts index 20e77249c..c310b7314 100644 --- a/typescript/packages/common-identity/src/pass-key.ts +++ b/typescript/packages/common-identity/src/pass-key.ts @@ -32,6 +32,10 @@ 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. @@ -67,7 +71,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 = { @@ -107,7 +111,7 @@ export class PassKey { throw new Error("common-identity: prf extension not supported."); } - return result; + return new PassKey(result); } // Retrieve a `PassKey` from a Web Authn authenticator. From aed155e4daf461044798c83b623b25b77693f04c Mon Sep 17 00:00:00 2001 From: Ben Follington <5009316+bfollington@users.noreply.github.com> Date: Tue, 25 Feb 2025 12:31:45 +1000 Subject: [PATCH 3/9] Testing every permutation of methods and sequences --- .../src/contexts/AuthenticationContext.tsx | 7 +- .../packages/jumble/src/utils/credentials.ts | 46 ++++ .../jumble/src/views/AuthenticationView.tsx | 211 +++++++++++------- 3 files changed, 182 insertions(+), 82 deletions(-) create mode 100644 typescript/packages/jumble/src/utils/credentials.ts diff --git a/typescript/packages/jumble/src/contexts/AuthenticationContext.tsx b/typescript/packages/jumble/src/contexts/AuthenticationContext.tsx index 5d4ccd4e4..73b44171d 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: (descriptor?: PublicKeyCredentialDescriptor) => Promise; + passkeyAuthenticate: (descriptor?: PublicKeyCredentialDescriptor) => Promise; // Generate a passphrase for a new user passphraseRegister: () => Promise; // Authenticate via passphrase. @@ -89,13 +89,14 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = ( const passkeyAuthenticate = useCallback( async (key?: PublicKeyCredentialDescriptor) => { if (!keyStore) { - return; + throw new Error("Key store not initialized"); } // Pass the keyName to PassKey.get() if provided const passkey = await PassKey.get({ allowCredentials: key ? [key] : [] }); const root = await passkey.createRootKey(); await keyStore.set(ROOT_KEY, root); setRoot(root); + return passkey; }, [keyStore], ); diff --git a/typescript/packages/jumble/src/utils/credentials.ts b/typescript/packages/jumble/src/utils/credentials.ts new file mode 100644 index 000000000..6c4d617ec --- /dev/null +++ b/typescript/packages/jumble/src/utils/credentials.ts @@ -0,0 +1,46 @@ +export interface StoredCredential { + id: string; + type: "public-key" | "passphrase"; + method: "passkey" | "passphrase"; +} + +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, + type: "public-key", + method: "passkey", + }; +} + +export function createPassphraseCredential(): StoredCredential { + return { + id: crypto.randomUUID(), + type: "passphrase", + method: "passphrase", + }; +} + +export function getPublicKeyCredentialDescriptor( + storedCredential: StoredCredential | null, +): PublicKeyCredentialDescriptor | undefined { + if (storedCredential?.type === "public-key") { + 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 75744ddef..9e5315840 100644 --- a/typescript/packages/jumble/src/views/AuthenticationView.tsx +++ b/typescript/packages/jumble/src/views/AuthenticationView.tsx @@ -14,10 +14,18 @@ import { LuTrash2, LuCheck, } from "react-icons/lu"; +import { + type StoredCredential, + getStoredCredential, + saveCredential, + clearStoredCredential, + createPasskeyCredential, + createPassphraseCredential, + getPublicKeyCredentialDescriptor, +} from "@/utils/credentials"; 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`; -const INPUT_STYLE = `w-full p-2 border rounded`; type AuthMethod = "passkey" | "passphrase"; type AuthFlow = "register" | "login"; @@ -31,12 +39,7 @@ interface SuccessRegistrationProps { mnemonic?: string; onLogin: () => void; method: AuthMethod; -} - -interface StoredCredential { - id: string; - type: "public-key" | "passphrase"; - method: AuthMethod; + credentialId?: string; } function ErrorCallout({ error, onDismiss }: ErrorCalloutProps) { @@ -49,7 +52,13 @@ function ErrorCallout({ error, onDismiss }: ErrorCalloutProps) {
); } -function SuccessRegistration({ mnemonic, onLogin, method }: SuccessRegistrationProps) { + +function SuccessRegistration({ + mnemonic, + onLogin, + method, + credentialId, +}: SuccessRegistrationProps) { const [copied, setCopied] = useState(false); const copyToClipboard = () => { @@ -62,29 +71,38 @@ function SuccessRegistration({ mnemonic, onLogin, method }: SuccessRegistrationP return (
- {mnemonic && ( + {method === "passkey" ? (
-

Your Secret Recovery Phrase:

-
-