diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 4819a84..fec3e46 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -63,11 +63,10 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher { export function normalizeCipherLoginForStorage(login: any): any { if (!login || typeof login !== 'object') return login ?? null; - - const rest = { ...login }; - const passkeyField = ['f', 'i', 'd', 'o', '2', 'C', 'r', 'e', 'd', 'e', 'n', 't', 'i', 'a', 'l', 's'].join(''); - delete (rest as Record)[passkeyField]; - return rest; + return { + ...login, + fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null, + }; } export function normalizeCipherLoginForCompatibility(login: any): any { diff --git a/src/handlers/import.ts b/src/handlers/import.ts index e658710..51bee18 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -183,6 +183,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st })) || null, totp: c.login.totp ?? null, autofillOnPageLoad: c.login.autofillOnPageLoad ?? null, + fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null, uri: c.login.uri ?? null, passwordRevisionDate: c.login.passwordRevisionDate ?? null, } : null, diff --git a/src/types/index.ts b/src/types/index.ts index 9f63723..b017d3b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -94,6 +94,7 @@ export interface CipherLogin { uris: CipherLoginUri[] | null; totp: string | null; autofillOnPageLoad: boolean | null; + fido2Credentials: any[] | null; uri: string | null; passwordRevisionDate: string | null; } diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 8d74399..df35f67 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -16,6 +16,7 @@ import { draftFromCipher, buildCipherDuplicateSignature, firstCipherUri, + firstPasskeyCreationTime, isCipherVisibleInArchive, isCipherVisibleInNormalVault, isCipherVisibleInTrash, @@ -971,6 +972,7 @@ function folderName(id: string | null | undefined): string { repromptApprovedCipherId={repromptApprovedCipherId} showPassword={showPassword} totpLive={totpLive} + passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)} hiddenFieldVisibleMap={hiddenFieldVisibleMap} folderName={folderName} onOpenReprompt={() => setRepromptOpen(true)} diff --git a/webapp/src/components/vault/VaultDetailView.tsx b/webapp/src/components/vault/VaultDetailView.tsx index d724a27..80b3884 100644 --- a/webapp/src/components/vault/VaultDetailView.tsx +++ b/webapp/src/components/vault/VaultDetailView.tsx @@ -20,6 +20,7 @@ interface VaultDetailViewProps { repromptApprovedCipherId: string | null; showPassword: boolean; totpLive: { code: string; remain: number } | null; + passkeyCreatedAt: string | null; hiddenFieldVisibleMap: Record; folderName: (id: string | null | undefined) => string; downloadingAttachmentKey: string; @@ -135,6 +136,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) { )} + {!!props.passkeyCreatedAt && ( +
+ {t('txt_passkey')} +
+ {t('txt_passkey_created_at_value', { value: formatHistoryTime(props.passkeyCreatedAt) })} +
+
+
+ )}
)} diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 82e914e..cdc67a3 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -194,6 +194,9 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string { uri: valueOrFallback(uri.decUri ?? uri.uri), match: uri.match ?? null, })), + fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({ + creationDate: valueOrFallback(credential.creationDate), + })), } : null, card: cipher.card @@ -262,6 +265,7 @@ export function createEmptyDraft(type: number): VaultDraft { loginPassword: '', loginTotp: '', loginUris: [createEmptyLoginUri()], + loginFido2Credentials: [], cardholderName: '', cardNumber: '', cardBrand: '', @@ -310,6 +314,9 @@ export function draftFromCipher(cipher: Cipher): VaultDraft { uri: x.decUri || x.uri || '', 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 (cipher.card) { @@ -406,6 +413,16 @@ export function creationTimeValue(cipher: Cipher): number { 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(); export function VaultListIcon({ cipher }: { cipher: Cipher }) { diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 7f3357f..56b80b3 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -393,6 +393,56 @@ function toIsoDateOrNow(value: unknown): string { return parsed.toISOString(); } +async function encryptMaybeFidoValue( + value: unknown, + enc: Uint8Array, + mac: Uint8Array, + fallback = '' +): Promise { + 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 { + 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> | null | undefined, + enc: Uint8Array, + mac: Uint8Array +): Promise> | null> { + if (!Array.isArray(credentials) || credentials.length === 0) return null; + const out: Array> = []; + 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) { diff --git a/webapp/src/lib/app-support.ts b/webapp/src/lib/app-support.ts index 70f403f..4404edc 100644 --- a/webapp/src/lib/app-support.ts +++ b/webapp/src/lib/app-support.ts @@ -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, 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 => !!item && typeof item === 'object') + : []; } else if (type === 3) { const card = (cipher.card || {}) as Record; draft.cardholderName = asText(card.cardholderName); diff --git a/webapp/src/lib/export-formats.ts b/webapp/src/lib/export-formats.ts index cf6f673..685d1ea 100644 --- a/webapp/src/lib/export-formats.ts +++ b/webapp/src/lib/export-formats.ts @@ -198,6 +198,7 @@ function mapCipherEncrypted(cipher: Cipher): Record { 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; diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index f561dab..5a911bc 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -571,6 +571,8 @@ const messages: Record> = { 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 = { 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: '请输入主密码', diff --git a/webapp/src/lib/import-formats-bitwarden.ts b/webapp/src/lib/import-formats-bitwarden.ts index d276526..ad14a35 100644 --- a/webapp/src/lib/import-formats-bitwarden.ts +++ b/webapp/src/lib/import-formats-bitwarden.ts @@ -31,6 +31,7 @@ export interface BitwardenCipherInput { username?: string | null; password?: string | null; totp?: string | null; + fido2Credentials?: Array> | null; } | null; card?: Record | null; identity?: Record | 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, diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 59c4258..f602fca 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -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>; cardholderName: string; cardNumber: string; cardBrand: string;