feat: add support for SSH key fingerprint normalization and compatibility

This commit is contained in:
shuaiplus
2026-03-04 22:45:30 +08:00
parent 36f398b728
commit 8df3221078
8 changed files with 90 additions and 17 deletions
+2 -6
View File
@@ -7,13 +7,9 @@
"main": "src/index.ts", "main": "src/index.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "npm run web:build && wrangler dev -c wrangler.toml", "dev": "wrangler dev -c wrangler.toml",
"dev:worker": "wrangler dev -c wrangler.toml",
"web:dev": "vite --config webapp/vite.config.ts",
"web:build": "vite build --config webapp/vite.config.ts",
"web:typecheck": "tsc -p webapp/tsconfig.json --noEmit",
"build": "vite build --config webapp/vite.config.ts", "build": "vite build --config webapp/vite.config.ts",
"deploy": "npm run build && wrangler deploy" "deploy": "wrangler deploy"
}, },
"keywords": [ "keywords": [
"bitwarden", "bitwarden",
+26
View File
@@ -40,6 +40,28 @@ export function normalizeCipherLoginForCompatibility(login: any): any {
}; };
} }
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
// Keep legacy alias "fingerprint" in parallel for older web payloads.
export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
if (!sshKey || typeof sshKey !== 'object') return sshKey ?? null;
const candidate =
sshKey.keyFingerprint !== undefined && sshKey.keyFingerprint !== null
? sshKey.keyFingerprint
: sshKey.fingerprint;
const normalizedFingerprint =
candidate === undefined || candidate === null
? ''
: String(candidate);
return {
...sshKey,
keyFingerprint: normalizedFingerprint,
fingerprint: normalizedFingerprint,
};
}
// Format attachments for API response // Format attachments for API response
export function formatAttachments(attachments: Attachment[]): any[] | null { export function formatAttachments(attachments: Attachment[]): any[] | null {
if (attachments.length === 0) return null; if (attachments.length === 0) return null;
@@ -63,6 +85,7 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
// Strip internal-only fields that must not appear in the API response // Strip internal-only fields that must not appear in the API response
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher; const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null); const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
return { return {
// Pass through ALL stored cipher fields (known + unknown) // Pass through ALL stored cipher fields (known + unknown)
@@ -85,6 +108,7 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = [])
collectionIds: [], collectionIds: [],
attachments: formatAttachments(attachments), attachments: formatAttachments(attachments),
login: normalizedLogin, login: normalizedLogin,
sshKey: normalizedSshKey,
encryptedFor: null, encryptedFor: null,
}; };
} }
@@ -181,6 +205,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
deletedAt: null, deletedAt: null,
}; };
cipher.login = normalizeCipherLoginForCompatibility(cipher.login); cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
@@ -232,6 +257,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
deletedAt: existingCipher.deletedAt, deletedAt: existingCipher.deletedAt,
}; };
cipher.login = normalizeCipherLoginForCompatibility(cipher.login); cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
// Custom fields deletion compatibility: // Custom fields deletion compatibility:
// - Accept both camelCase "fields" and PascalCase "Fields". // - Accept both camelCase "fields" and PascalCase "Fields".
+2 -2
View File
@@ -3,7 +3,7 @@ import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { normalizeCipherLoginForCompatibility } from './ciphers'; import { normalizeCipherLoginForCompatibility, normalizeCipherSshKeyForCompatibility } from './ciphers';
// Bitwarden client import request format // Bitwarden client import request format
interface CiphersImportRequest { interface CiphersImportRequest {
@@ -226,7 +226,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null, })) || null,
passwordHistory: c.passwordHistory ?? null, passwordHistory: c.passwordHistory ?? null,
reprompt: c.reprompt ?? 0, reprompt: c.reprompt ?? 0,
sshKey: (c as any).sshKey ?? null, sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null),
key: (c as any).key ?? null, key: (c as any).key ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
+5 -2
View File
@@ -251,7 +251,7 @@ function importCipherToDraft(cipher: Record<string, unknown>, folderId: string |
const sshKey = (cipher.sshKey || {}) as Record<string, unknown>; const sshKey = (cipher.sshKey || {}) as Record<string, unknown>;
draft.sshPrivateKey = asText(sshKey.privateKey); draft.sshPrivateKey = asText(sshKey.privateKey);
draft.sshPublicKey = asText(sshKey.publicKey); draft.sshPublicKey = asText(sshKey.publicKey);
draft.sshFingerprint = asText(sshKey.fingerprint); draft.sshFingerprint = asText(sshKey.keyFingerprint ?? sshKey.fingerprint);
} }
return draft; return draft;
@@ -703,11 +703,14 @@ export default function App() {
}; };
} }
if (cipher.sshKey) { if (cipher.sshKey) {
const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || '';
nextCipher.sshKey = { nextCipher.sshKey = {
...cipher.sshKey, ...cipher.sshKey,
decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac), decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac),
decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac), decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac),
decFingerprint: await decryptField(cipher.sshKey.fingerprint || '', itemEnc, itemMac), keyFingerprint: encryptedFingerprint || null,
fingerprint: encryptedFingerprint || null,
decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac),
}; };
} }
if (cipher.fields) { if (cipher.fields) {
+27 -3
View File
@@ -1461,9 +1461,33 @@ function folderName(id: string | null | undefined): string {
{selectedCipher.sshKey && ( {selectedCipher.sshKey && (
<div className="card"> <div className="card">
<h4>{t('txt_ssh_key')}</h4> <h4>{t('txt_ssh_key')}</h4>
<div className="kv-line"><span>{t('txt_private_key')}</span><strong>{maskSecret(selectedCipher.sshKey.decPrivateKey || '')}</strong></div> <div className="kv-row">
<div className="kv-line"><span>{t('txt_public_key')}</span><strong>{selectedCipher.sshKey.decPublicKey || ''}</strong></div> <span className="kv-label">{t('txt_private_key')}</span>
<div className="kv-line"><span>{t('txt_fingerprint')}</span><strong>{selectedCipher.sshKey.decFingerprint || ''}</strong></div> <div className="kv-main">
<strong className="value-ellipsis" title={maskSecret(selectedCipher.sshKey.decPrivateKey || '')}>
{maskSecret(selectedCipher.sshKey.decPrivateKey || '')}
</strong>
</div>
<div className="kv-actions" />
</div>
<div className="kv-row">
<span className="kv-label">{t('txt_public_key')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={selectedCipher.sshKey.decPublicKey || ''}>
{selectedCipher.sshKey.decPublicKey || ''}
</strong>
</div>
<div className="kv-actions" />
</div>
<div className="kv-row">
<span className="kv-label">{t('txt_fingerprint')}</span>
<div className="kv-main">
<strong className="value-ellipsis" title={selectedCipher.sshKey.decFingerprint || ''}>
{selectedCipher.sshKey.decFingerprint || ''}
</strong>
</div>
<div className="kv-actions" />
</div>
</div> </div>
)} )}
+8 -2
View File
@@ -971,10 +971,13 @@ export async function createCipher(
country: await encryptTextValue(draft.identCountry, enc, mac), country: await encryptTextValue(draft.identCountry, enc, mac),
}; };
} else if (type === 5) { } else if (type === 5) {
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, enc, mac);
payload.sshKey = { payload.sshKey = {
privateKey: await encryptTextValue(draft.sshPrivateKey, enc, mac), privateKey: await encryptTextValue(draft.sshPrivateKey, enc, mac),
publicKey: await encryptTextValue(draft.sshPublicKey, enc, mac), publicKey: await encryptTextValue(draft.sshPublicKey, enc, mac),
fingerprint: await encryptTextValue(draft.sshFingerprint, enc, mac), keyFingerprint: encryptedFingerprint,
// Keep legacy alias for backward compatibility with previously exported/edited items.
fingerprint: encryptedFingerprint,
}; };
} else if (type === 2) { } else if (type === 2) {
payload.secureNote = { type: 0 }; payload.secureNote = { type: 0 };
@@ -1063,10 +1066,13 @@ export async function updateCipher(
country: await encryptTextValue(draft.identCountry, keys.enc, keys.mac), country: await encryptTextValue(draft.identCountry, keys.enc, keys.mac),
}; };
} else if (type === 5) { } else if (type === 5) {
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac);
payload.sshKey = { payload.sshKey = {
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac), privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac), publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
fingerprint: await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac), keyFingerprint: encryptedFingerprint,
// Keep legacy alias for backward compatibility with previously exported/edited items.
fingerprint: encryptedFingerprint,
}; };
} else if (type === 2) { } else if (type === 2) {
payload.secureNote = { type: 0 }; payload.secureNote = { type: 0 };
+19 -2
View File
@@ -257,7 +257,9 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
? { ? {
privateKey: cipher.sshKey.privateKey ?? null, privateKey: cipher.sshKey.privateKey ?? null,
publicKey: cipher.sshKey.publicKey ?? null, publicKey: cipher.sshKey.publicKey ?? null,
fingerprint: cipher.sshKey.fingerprint ?? null, keyFingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
// Keep legacy alias for compatibility with older importers.
fingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
} }
: null; : null;
@@ -304,7 +306,22 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
out.card = cipher.card ? await deepDecryptUnknown(cipher.card, keyParts.enc, keyParts.mac) : null; out.card = cipher.card ? await deepDecryptUnknown(cipher.card, keyParts.enc, keyParts.mac) : null;
out.identity = cipher.identity ? await deepDecryptUnknown(cipher.identity, keyParts.enc, keyParts.mac) : null; out.identity = cipher.identity ? await deepDecryptUnknown(cipher.identity, keyParts.enc, keyParts.mac) : null;
out.sshKey = cipher.sshKey ? await deepDecryptUnknown(cipher.sshKey, keyParts.enc, keyParts.mac) : null; if (cipher.sshKey) {
const fingerprint = await decryptMaybe(
cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
keyParts.enc,
keyParts.mac
);
out.sshKey = {
privateKey: await decryptMaybe(cipher.sshKey.privateKey ?? null, keyParts.enc, keyParts.mac),
publicKey: await decryptMaybe(cipher.sshKey.publicKey ?? null, keyParts.enc, keyParts.mac),
keyFingerprint: fingerprint,
// Keep legacy alias for compatibility with older importers.
fingerprint,
};
} else {
out.sshKey = null;
}
out.secureNote = cipher.secureNote out.secureNote = cipher.secureNote
? { ? {
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0), type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
+1
View File
@@ -112,6 +112,7 @@ export interface CipherIdentity {
export interface CipherSshKey { export interface CipherSshKey {
privateKey?: string | null; privateKey?: string | null;
publicKey?: string | null; publicKey?: string | null;
keyFingerprint?: string | null;
fingerprint?: string | null; fingerprint?: string | null;
decPrivateKey?: string; decPrivateKey?: string;
decPublicKey?: string; decPublicKey?: string;