Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 28 additions & 14 deletions typescript/packages/common-identity/src/pass-key.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Identity> {
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);
Expand Down Expand Up @@ -66,15 +74,15 @@ 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<void> {
static async create(name: string, displayName: string): Promise<PassKey> {
const challenge = random(32);
const userId = random(32);
const user = {
id: userId,
name,
displayName,
};

let publicKey: PublicKeyCredentialCreationOptions = {
challenge,
rp: { id: RP_ID, name: RP },
Expand All @@ -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");
Expand All @@ -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<PassKey> {
static async get({
userVerification,
mediation,
allowCredentials = [],
}: PassKeyGetOptions = {}): Promise<PassKey> {
// 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.");
Expand All @@ -136,7 +150,7 @@ export class PassKey {
}
return new PassKey(credential);
}

private getCredentials(): PublicKeyCredential {
return this.credentials;
}
Expand Down
91 changes: 50 additions & 41 deletions typescript/packages/jumble/src/contexts/AuthenticationContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ interface AuthenticationContextType {
// The authenticated user/persona.
user: Identity | void;
// Call PassKey registration.
passkeyRegister: (name: string, displayName: string) => Promise<void>;
passkeyRegister: (name: string, displayName: string) => Promise<PassKey>;
// Authenticate the user via passkey.
passkeyAuthenticate: () => Promise<void>;
passkeyAuthenticate: (descriptor?: PublicKeyCredentialDescriptor) => Promise<PassKey>;
// Generate a passphrase for a new user
passphraseRegister: () => Promise<string>;
// Authenticate via passphrase.
Expand All @@ -21,7 +21,7 @@ interface AuthenticationContextType {
clearAuthentication: () => Promise<void>;
// Internal: Root key.
root: Identity | void;
// Internal: Persistent storage for keys.
// Internal: Persistent storage for keys.
keyStore: KeyStore | void;
}

Expand All @@ -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
Expand All @@ -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);
}
Expand All @@ -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
Expand All @@ -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;
Expand All @@ -125,16 +132,18 @@ export const AuthenticationProvider: React.FC<{ children: React.ReactNode }> = (
}, [keyStore]);

return (
<AuthenticationContext.Provider value={{
user,
passkeyAuthenticate,
passkeyRegister,
passphraseAuthenticate,
passphraseRegister,
clearAuthentication,
root,
keyStore,
}}>
<AuthenticationContext.Provider
value={{
user,
passkeyAuthenticate,
passkeyRegister,
passphraseAuthenticate,
passphraseRegister,
clearAuthentication,
root,
keyStore,
}}
>
{children}
</AuthenticationContext.Provider>
);
Expand Down
48 changes: 48 additions & 0 deletions typescript/packages/jumble/src/utils/credentials.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading