feat: add FIDO2 credentials support in cipher handling and UI components

This commit is contained in:
shuaiplus
2026-04-08 14:40:49 +08:00
parent c516194d54
commit 5bf7c79ada
12 changed files with 113 additions and 5 deletions
+55
View File
@@ -393,6 +393,56 @@ function toIsoDateOrNow(value: unknown): string {
return parsed.toISOString();
}
async function encryptMaybeFidoValue(
value: unknown,
enc: Uint8Array,
mac: Uint8Array,
fallback = ''
): Promise<string> {
const normalized = String(value ?? '').trim() || fallback;
if (looksLikeCipherString(normalized)) return normalized;
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
}
async function encryptMaybeNullableFidoValue(
value: unknown,
enc: Uint8Array,
mac: Uint8Array
): Promise<string | null> {
const normalized = String(value ?? '').trim();
if (!normalized) return null;
if (looksLikeCipherString(normalized)) return normalized;
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
}
async function normalizeFido2Credentials(
credentials: Array<Record<string, unknown>> | null | undefined,
enc: Uint8Array,
mac: Uint8Array
): Promise<Array<Record<string, unknown>> | null> {
if (!Array.isArray(credentials) || credentials.length === 0) return null;
const out: Array<Record<string, unknown>> = [];
for (const credential of credentials) {
if (!credential || typeof credential !== 'object') continue;
out.push({
credentialId: await encryptMaybeFidoValue(credential.credentialId, enc, mac),
keyType: await encryptMaybeFidoValue(credential.keyType, enc, mac, 'public-key'),
keyAlgorithm: await encryptMaybeFidoValue(credential.keyAlgorithm, enc, mac, 'ECDSA'),
keyCurve: await encryptMaybeFidoValue(credential.keyCurve, enc, mac, 'P-256'),
keyValue: await encryptMaybeFidoValue(credential.keyValue, enc, mac),
rpId: await encryptMaybeFidoValue(credential.rpId, enc, mac),
rpName: await encryptMaybeNullableFidoValue(credential.rpName, enc, mac),
userHandle: await encryptMaybeNullableFidoValue(credential.userHandle, enc, mac),
userName: await encryptMaybeNullableFidoValue(credential.userName, enc, mac),
userDisplayName: await encryptMaybeNullableFidoValue(credential.userDisplayName, enc, mac),
counter: await encryptMaybeFidoValue(credential.counter, enc, mac, '0'),
discoverable: await encryptMaybeFidoValue(credential.discoverable, enc, mac, 'false'),
creationDate: toIsoDateOrNow(credential.creationDate),
});
}
return out.length ? out : null;
}
async function getCipherKeys(
cipher: Cipher | null,
userEnc: Uint8Array,
@@ -441,10 +491,15 @@ async function buildCipherPayload(
}
if (type === 1) {
const existingFido2 =
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials
: draft.loginFido2Credentials;
payload.login = {
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
};
} else if (type === 3) {
+4
View File
@@ -99,6 +99,7 @@ export function buildEmptyImportDraft(type: number): VaultDraft {
loginPassword: '',
loginTotp: '',
loginUris: [{ uri: '', match: null }],
loginFido2Credentials: [],
cardholderName: '',
cardNumber: '',
cardBrand: '',
@@ -173,6 +174,9 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
})
.filter((u) => !!u.uri);
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }];
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
: [];
} else if (type === 3) {
const card = (cipher.card || {}) as Record<string, unknown>;
draft.cardholderName = asText(card.cardholderName);
+6
View File
@@ -198,6 +198,7 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
match: (uri as { match?: unknown })?.match ?? null,
}))
: [],
fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [],
}
: null;
@@ -291,6 +292,11 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
}))
)
: [],
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
? await Promise.all(
cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac))
)
: [],
};
} else {
out.login = null;
+4
View File
@@ -571,6 +571,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_password_hint_not_set: "No password hint is available for this email.",
txt_password_hint_load_failed: "Failed to load password hint",
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
txt_passkey: "Passkey",
txt_passkey_created_at_value: "Created on {value}",
txt_phone: "Phone",
txt_please_input_email_and_password: "Please input email and password",
txt_please_input_master_password: "Please input master password",
@@ -1324,6 +1326,8 @@ const zhCNOverrides: Record<string, string> = {
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
txt_password_hint_load_failed: '加载密码提示失败',
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
txt_passkey: '通行密钥',
txt_passkey_created_at_value: '创建于 {value}',
txt_phone: '电话',
txt_please_input_email_and_password: '请输入邮箱和密码',
txt_please_input_master_password: '请输入主密码',
@@ -31,6 +31,7 @@ export interface BitwardenCipherInput {
username?: string | null;
password?: string | null;
totp?: string | null;
fido2Credentials?: Array<Record<string, unknown>> | null;
} | null;
card?: Record<string, unknown> | null;
identity?: Record<string, unknown> | null;
@@ -89,6 +90,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
username: item.login.username ?? null,
password: item.login.password ?? null,
totp: item.login.totp ?? null,
fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null,
uris: Array.isArray(item.login.uris)
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null }))
: null,
+7
View File
@@ -49,11 +49,17 @@ export interface CipherAttachment {
object?: string;
}
export interface CipherLoginPasskey {
creationDate?: string | null;
[key: string]: unknown;
}
export interface CipherLogin {
username?: string | null;
password?: string | null;
totp?: string | null;
uris?: CipherLoginUri[] | null;
fido2Credentials?: CipherLoginPasskey[] | null;
decUsername?: string;
decPassword?: string;
decTotp?: string;
@@ -223,6 +229,7 @@ export interface VaultDraft {
loginPassword: string;
loginTotp: string;
loginUris: VaultDraftLoginUri[];
loginFido2Credentials: Array<Record<string, unknown>>;
cardholderName: string;
cardNumber: string;
cardBrand: string;