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
+4 -5
View File
@@ -63,11 +63,10 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher {
export function normalizeCipherLoginForStorage(login: any): any { export function normalizeCipherLoginForStorage(login: any): any {
if (!login || typeof login !== 'object') return login ?? null; if (!login || typeof login !== 'object') return login ?? null;
return {
const rest = { ...login }; ...login,
const passkeyField = ['f', 'i', 'd', 'o', '2', 'C', 'r', 'e', 'd', 'e', 'n', 't', 'i', 'a', 'l', 's'].join(''); fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
delete (rest as Record<string, unknown>)[passkeyField]; };
return rest;
} }
export function normalizeCipherLoginForCompatibility(login: any): any { export function normalizeCipherLoginForCompatibility(login: any): any {
+1
View File
@@ -183,6 +183,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null, })) || null,
totp: c.login.totp ?? null, totp: c.login.totp ?? null,
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null,
uri: c.login.uri ?? null, uri: c.login.uri ?? null,
passwordRevisionDate: c.login.passwordRevisionDate ?? null, passwordRevisionDate: c.login.passwordRevisionDate ?? null,
} : null, } : null,
+1
View File
@@ -94,6 +94,7 @@ export interface CipherLogin {
uris: CipherLoginUri[] | null; uris: CipherLoginUri[] | null;
totp: string | null; totp: string | null;
autofillOnPageLoad: boolean | null; autofillOnPageLoad: boolean | null;
fido2Credentials: any[] | null;
uri: string | null; uri: string | null;
passwordRevisionDate: string | null; passwordRevisionDate: string | null;
} }
+2
View File
@@ -16,6 +16,7 @@ import {
draftFromCipher, draftFromCipher,
buildCipherDuplicateSignature, buildCipherDuplicateSignature,
firstCipherUri, firstCipherUri,
firstPasskeyCreationTime,
isCipherVisibleInArchive, isCipherVisibleInArchive,
isCipherVisibleInNormalVault, isCipherVisibleInNormalVault,
isCipherVisibleInTrash, isCipherVisibleInTrash,
@@ -971,6 +972,7 @@ function folderName(id: string | null | undefined): string {
repromptApprovedCipherId={repromptApprovedCipherId} repromptApprovedCipherId={repromptApprovedCipherId}
showPassword={showPassword} showPassword={showPassword}
totpLive={totpLive} totpLive={totpLive}
passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)}
hiddenFieldVisibleMap={hiddenFieldVisibleMap} hiddenFieldVisibleMap={hiddenFieldVisibleMap}
folderName={folderName} folderName={folderName}
onOpenReprompt={() => setRepromptOpen(true)} onOpenReprompt={() => setRepromptOpen(true)}
@@ -20,6 +20,7 @@ interface VaultDetailViewProps {
repromptApprovedCipherId: string | null; repromptApprovedCipherId: string | null;
showPassword: boolean; showPassword: boolean;
totpLive: { code: string; remain: number } | null; totpLive: { code: string; remain: number } | null;
passkeyCreatedAt: string | null;
hiddenFieldVisibleMap: Record<number, boolean>; hiddenFieldVisibleMap: Record<number, boolean>;
folderName: (id: string | null | undefined) => string; folderName: (id: string | null | undefined) => string;
downloadingAttachmentKey: string; downloadingAttachmentKey: string;
@@ -135,6 +136,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
</div> </div>
</div> </div>
)} )}
{!!props.passkeyCreatedAt && (
<div className="kv-row">
<span className="kv-label">{t('txt_passkey')}</span>
<div className="kv-main">
<strong>{t('txt_passkey_created_at_value', { value: formatHistoryTime(props.passkeyCreatedAt) })}</strong>
</div>
<div className="kv-actions" />
</div>
)}
</div> </div>
)} )}
@@ -194,6 +194,9 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
uri: valueOrFallback(uri.decUri ?? uri.uri), uri: valueOrFallback(uri.decUri ?? uri.uri),
match: uri.match ?? null, match: uri.match ?? null,
})), })),
fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({
creationDate: valueOrFallback(credential.creationDate),
})),
} }
: null, : null,
card: cipher.card card: cipher.card
@@ -262,6 +265,7 @@ export function createEmptyDraft(type: number): VaultDraft {
loginPassword: '', loginPassword: '',
loginTotp: '', loginTotp: '',
loginUris: [createEmptyLoginUri()], loginUris: [createEmptyLoginUri()],
loginFido2Credentials: [],
cardholderName: '', cardholderName: '',
cardNumber: '', cardNumber: '',
cardBrand: '', cardBrand: '',
@@ -310,6 +314,9 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
uri: x.decUri || x.uri || '', uri: x.decUri || x.uri || '',
match: x.match ?? null, match: x.match ?? null,
})); }));
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
: [];
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()]; if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
} }
if (cipher.card) { if (cipher.card) {
@@ -406,6 +413,16 @@ export function creationTimeValue(cipher: Cipher): number {
return Number.isFinite(time) ? time : 0; return Number.isFinite(time) ? time : 0;
} }
export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
const credentials = cipher?.login?.fido2Credentials;
if (!Array.isArray(credentials) || credentials.length === 0) return null;
for (const credential of credentials) {
const raw = String(credential?.creationDate || '').trim();
if (raw) return raw;
}
return null;
}
const failedIconHosts = new Set<string>(); const failedIconHosts = new Set<string>();
export function VaultListIcon({ cipher }: { cipher: Cipher }) { export function VaultListIcon({ cipher }: { cipher: Cipher }) {
+55
View File
@@ -393,6 +393,56 @@ function toIsoDateOrNow(value: unknown): string {
return parsed.toISOString(); 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( async function getCipherKeys(
cipher: Cipher | null, cipher: Cipher | null,
userEnc: Uint8Array, userEnc: Uint8Array,
@@ -441,10 +491,15 @@ async function buildCipherPayload(
} }
if (type === 1) { if (type === 1) {
const existingFido2 =
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials
: draft.loginFido2Credentials;
payload.login = { payload.login = {
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac), password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
totp: await encryptTextValue(draft.loginTotp, 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), uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
}; };
} else if (type === 3) { } else if (type === 3) {
+4
View File
@@ -99,6 +99,7 @@ export function buildEmptyImportDraft(type: number): VaultDraft {
loginPassword: '', loginPassword: '',
loginTotp: '', loginTotp: '',
loginUris: [{ uri: '', match: null }], loginUris: [{ uri: '', match: null }],
loginFido2Credentials: [],
cardholderName: '', cardholderName: '',
cardNumber: '', cardNumber: '',
cardBrand: '', cardBrand: '',
@@ -173,6 +174,9 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
}) })
.filter((u) => !!u.uri); .filter((u) => !!u.uri);
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }]; 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) { } else if (type === 3) {
const card = (cipher.card || {}) as Record<string, unknown>; const card = (cipher.card || {}) as Record<string, unknown>;
draft.cardholderName = asText(card.cardholderName); 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, match: (uri as { match?: unknown })?.match ?? null,
})) }))
: [], : [],
fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [],
} }
: null; : 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 { } else {
out.login = null; 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_not_set: "No password hint is available for this email.",
txt_password_hint_load_failed: "Failed to load password hint", txt_password_hint_load_failed: "Failed to load password hint",
txt_password_hint_too_long: "Password hint must be 120 characters or fewer", 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_phone: "Phone",
txt_please_input_email_and_password: "Please input email and password", txt_please_input_email_and_password: "Please input email and password",
txt_please_input_master_password: "Please input master 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_not_set: '这个邮箱没有可显示的密码提示。',
txt_password_hint_load_failed: '加载密码提示失败', txt_password_hint_load_failed: '加载密码提示失败',
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符', txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
txt_passkey: '通行密钥',
txt_passkey_created_at_value: '创建于 {value}',
txt_phone: '电话', txt_phone: '电话',
txt_please_input_email_and_password: '请输入邮箱和密码', txt_please_input_email_and_password: '请输入邮箱和密码',
txt_please_input_master_password: '请输入主密码', txt_please_input_master_password: '请输入主密码',
@@ -31,6 +31,7 @@ export interface BitwardenCipherInput {
username?: string | null; username?: string | null;
password?: string | null; password?: string | null;
totp?: string | null; totp?: string | null;
fido2Credentials?: Array<Record<string, unknown>> | null;
} | null; } | null;
card?: Record<string, unknown> | null; card?: Record<string, unknown> | null;
identity?: Record<string, unknown> | null; identity?: Record<string, unknown> | null;
@@ -89,6 +90,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
username: item.login.username ?? null, username: item.login.username ?? null,
password: item.login.password ?? null, password: item.login.password ?? null,
totp: item.login.totp ?? null, totp: item.login.totp ?? null,
fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null,
uris: Array.isArray(item.login.uris) uris: Array.isArray(item.login.uris)
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null })) ? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null }))
: null, : null,
+7
View File
@@ -49,11 +49,17 @@ export interface CipherAttachment {
object?: string; object?: string;
} }
export interface CipherLoginPasskey {
creationDate?: string | null;
[key: string]: unknown;
}
export interface CipherLogin { export interface CipherLogin {
username?: string | null; username?: string | null;
password?: string | null; password?: string | null;
totp?: string | null; totp?: string | null;
uris?: CipherLoginUri[] | null; uris?: CipherLoginUri[] | null;
fido2Credentials?: CipherLoginPasskey[] | null;
decUsername?: string; decUsername?: string;
decPassword?: string; decPassword?: string;
decTotp?: string; decTotp?: string;
@@ -223,6 +229,7 @@ export interface VaultDraft {
loginPassword: string; loginPassword: string;
loginTotp: string; loginTotp: string;
loginUris: VaultDraftLoginUri[]; loginUris: VaultDraftLoginUri[];
loginFido2Credentials: Array<Record<string, unknown>>;
cardholderName: string; cardholderName: string;
cardNumber: string; cardNumber: string;
cardBrand: string; cardBrand: string;