@evolu/web
Version:
Evolu for web
330 lines (311 loc) • 10 kB
text/typescript
import { set, get, del, keys, clear, createStore } from "idb-keyval";
import {
createSlip21,
utf8ToBytes,
bytesToUtf8,
base64UrlToUint8Array,
uint8ArrayToBase64Url,
EncryptionKey,
Base64Url,
} from "@evolu/common";
import type {
AuthResult,
Entropy32,
SensitiveInfoItem,
SecureStorage,
RandomBytesDep,
SymmetricCryptoDep,
} from "@evolu/common";
import type { UseStore } from "idb-keyval";
/** @experimental */
export const createWebAuthnStore = (
deps: RandomBytesDep & SymmetricCryptoDep,
): SecureStorage => ({
setItem: async (key, value, options) => {
if (options?.accessControl === "none") {
const metadata = createMetadata(false);
await set(key, { value, metadata }, getStore(options.service));
return { metadata };
}
const seed = generateSeed(deps)();
const authResult = JSON.parse(value) as AuthResult;
const credential = await createCredential(deps)(
options?.webAuthnUsername ?? "Evolu User",
seed,
options?.relyingPartyID,
options?.relyingPartyName,
options?.webAuthnUserVerification,
options?.webAuthnAuthenticatorAttachment,
);
const encryptionKey = deriveEncryptionKey(seed);
const encryptedData = encryptAuthResult(deps)(authResult, encryptionKey);
const credentialId = uint8ArrayToBase64Url(
new Uint8Array(credential.rawId),
);
const metadata = createMetadata();
await set(
key,
{ credentialId, ...encryptedData, metadata },
getStore(options?.service),
);
return { metadata };
},
getItem: async (key, options) => {
if (options?.accessControl === "none") {
const data = await get<{
readonly value: string;
readonly metadata: SensitiveInfoItem["metadata"];
}>(key, getStore(options.service));
return data
? {
key,
value: data.value,
service: options.service ?? "default",
metadata: data.metadata,
}
: null;
}
const data = await get<{
readonly nonce: Base64Url;
readonly ciphertext: Base64Url;
readonly credentialId: string;
readonly metadata: SensitiveInfoItem["metadata"];
}>(key, getStore(options?.service));
if (!data) {
return null;
}
try {
const credential = await getCredential(deps)(
data.credentialId,
options?.relyingPartyID,
options?.webAuthnUserVerification,
);
const credentialSeed = extractSeedFromCredential(credential);
const encryptionKey = deriveEncryptionKey(credentialSeed);
const authResultVal = decryptAuthResult(deps)(data, encryptionKey);
if (!authResultVal) {
return null;
}
return {
key,
service: options?.service ?? "default",
value: authResultVal,
metadata: data.metadata,
};
} catch (_error) {
return null;
}
},
deleteItem: async (key, options) => {
await del(key, getStore(options?.service));
return true;
},
getAllItems: async (options) => {
const service = options?.service ?? "default";
const itemKeys = await keys<string>(getStore(service));
const items = await Promise.all(
itemKeys.map(async (key) => {
const data = await get<{
readonly metadata?: SensitiveInfoItem["metadata"];
readonly value?: string;
}>(key, getStore(service));
return {
key,
service,
metadata: data?.metadata ?? createMetadata(),
...(options?.includeValues && data?.value
? { value: data.value }
: {}),
};
}),
);
return items;
},
clearService: async (options) => {
await clear(getStore(options?.service));
},
});
/**
* Create default metadata for backwards compatibility with items that don't
* have stored metadata.
*/
const createMetadata = (isSecure = true): SensitiveInfoItem["metadata"] => {
return {
backend: "keychain",
accessControl: isSecure ? "biometryCurrentSet" : "none",
securityLevel: isSecure ? "biometry" : "software",
timestamp: Date.now(),
};
};
/** Get storage key for owner ID. (supports namespaces via prefix) */
const getStore = (prefix = "default"): UseStore => {
return createStore(prefix, "evolu-auth");
};
const createCredential =
(deps: RandomBytesDep) =>
async (
username: string,
seed: Uint8Array,
relyingPartyID?: string,
relyingPartyName?: string,
userVerification?: UserVerificationRequirement,
authenticatorAttachment?: AuthenticatorAttachment,
): Promise<PublicKeyCredential> => {
const options = createCredentialCreationOptions(deps)(
username,
seed,
relyingPartyID,
relyingPartyName,
userVerification,
authenticatorAttachment,
);
const credential = (await navigator.credentials.create(
options,
)) as PublicKeyCredential | null;
if (!credential) {
throw new Error("Failed to create WebAuthn credential");
}
return credential;
};
const getCredential =
(deps: RandomBytesDep) =>
async (
credentialId: string,
relyingPartyID?: string,
userVerification?: UserVerificationRequirement,
): Promise<PublicKeyCredential> => {
const options = createCredentialRequestOptions(deps)(
credentialId,
relyingPartyID,
userVerification,
);
const credential = (await navigator.credentials.get(
options,
)) as PublicKeyCredential | null;
if (!credential?.response) {
throw new Error("Failed to get WebAuthn credential");
}
return credential;
};
const extractSeedFromCredential = (
credential: PublicKeyCredential,
): Uint8Array => {
const response = credential.response as AuthenticatorAssertionResponse;
if (!response.userHandle) {
throw new Error("No userHandle in credential response");
}
return new Uint8Array(response.userHandle);
};
const createCredentialCreationOptions =
(deps: RandomBytesDep) =>
(
username: string,
seed: Uint8Array,
relyingPartyID?: string,
relyingPartyName?: string,
userVerification?: UserVerificationRequirement,
authenticatorAttachment?: AuthenticatorAttachment,
): CredentialCreationOptions => {
return {
publicKey: {
challenge: generateSeed(deps)() as BufferSource,
rp: {
id: relyingPartyID ?? document.location.hostname,
name: relyingPartyName ?? "Evolu",
},
user: {
id: seed as BufferSource,
name: username,
displayName: username,
},
pubKeyCredParams: [
{ type: "public-key", alg: -8 }, // Ed25519
{ type: "public-key", alg: -7 }, // ES256
{ type: "public-key", alg: -257 }, // RS256
],
attestation: "none",
authenticatorSelection: {
// - "platform": Uses the platform's built-in authenticator.
// - "cross-platform": Uses a device specific authenticator (yubikey, fido2, etc.)
authenticatorAttachment: authenticatorAttachment ?? "platform",
// - "discouraged": Only User Presence is needed.
// - "preferred": User Verification is preferred but not required. Falls back to User Presence.
// - "required": User Verification MUST occur (biometrics/PIN). Clients may silently downgrade to User Presence only.
userVerification: userVerification ?? "required",
// - "discouraged": Server-side credential is preferable, but will accept client-side discoverable credential.
// - "preferred": Relying Party strongly prefers client-side discoverable credential but will accept server-side credential.
// - "required": Client-side discoverable credential MUST be created, error if it can't be created.
residentKey: "required",
// Included for backwards compatibility. Deprecated in favor of residentKey (true = "required")
requireResidentKey: true,
},
},
};
};
const createCredentialRequestOptions =
(deps: RandomBytesDep) =>
(
credentialId: string,
relyingPartyID?: string,
userVerification?: UserVerificationRequirement,
): CredentialRequestOptions => {
return {
publicKey: {
challenge: generateSeed(deps)() as BufferSource,
rpId: relyingPartyID ?? document.location.hostname,
userVerification: userVerification ?? "preferred",
allowCredentials: [
{
type: "public-key",
id: base64UrlToUint8Array(
Base64Url.orThrow(credentialId),
) as BufferSource,
},
],
},
};
};
const deriveEncryptionKey = (seed: Uint8Array): EncryptionKey => {
const seed32 = seed.length === 32 ? seed : seed.slice(0, 32);
return EncryptionKey.orThrow(
createSlip21(seed32 as Entropy32, ["evolu", "auth"]),
);
};
const encryptAuthResult =
(deps: SymmetricCryptoDep) =>
(
authResult: AuthResult,
encryptionKey: EncryptionKey,
): {
nonce: Base64Url;
ciphertext: Base64Url;
} => {
const plaintext = utf8ToBytes(JSON.stringify(authResult));
const { nonce, ciphertext } = deps.symmetricCrypto.encrypt(
plaintext,
encryptionKey,
);
return {
nonce: uint8ArrayToBase64Url(nonce),
ciphertext: uint8ArrayToBase64Url(ciphertext),
};
};
const decryptAuthResult =
(deps: SymmetricCryptoDep) =>
(
encryptedData: { nonce: Base64Url; ciphertext: Base64Url },
encryptionKey: EncryptionKey,
): string | null => {
const nonce = base64UrlToUint8Array(encryptedData.nonce);
const ciphertext = base64UrlToUint8Array(encryptedData.ciphertext);
const result = deps.symmetricCrypto.decrypt(
ciphertext,
encryptionKey,
nonce,
);
if (!result.ok) return null;
return bytesToUtf8(result.value);
};
const generateSeed = (deps: RandomBytesDep) => () => {
return deps.randomBytes.create(32);
};