mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add FIDO2 credentials support in cipher handling and UI components
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user