mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add support for SSH key fingerprint normalization and compatibility
This commit is contained in:
+2
-6
@@ -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",
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user