From 52d2ee7592116d25ba35ee30ad5c3fec275b2fd6 Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:51:43 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(connection-package):=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=BF=9E=E6=8E=A5=E6=81=A2=E5=A4=8D=E5=8C=85?= =?UTF-8?q?=E5=8F=8C=E6=A8=A1=E5=BC=8F=E5=8A=A0=E5=AF=86=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路 - 扩展前后端导入导出流程并兼容 v1 与 legacy 格式 - 修复无文件密码恢复包导入误弹密码框导致的流程阻塞 --- frontend/src/App.tsx | 83 ++++- .../ConnectionPackagePasswordModal.tsx | 60 ++- frontend/src/main.browserMock.test.ts | 6 +- frontend/src/main.tsx | 4 +- frontend/src/utils/connectionExport.test.ts | 76 +++- frontend/src/utils/connectionExport.ts | 64 +++- frontend/wailsjs/go/app/App.d.ts | 2 +- frontend/wailsjs/go/models.ts | 14 + internal/app/connection_package_appkey.go | 196 ++++++++++ .../app/connection_package_appkey_test.go | 141 ++++++++ internal/app/connection_package_crypto.go | 341 +++++++++++++++++- .../app/connection_package_crypto_test.go | 180 +++++++++ internal/app/connection_package_transfer.go | 52 +++ .../app/connection_package_transfer_test.go | 180 +++++++++ internal/app/connection_package_types.go | 83 ++++- internal/app/methods_file.go | 14 +- 16 files changed, 1447 insertions(+), 49 deletions(-) create mode 100644 internal/app/connection_package_appkey.go create mode 100644 internal/app/connection_package_appkey_test.go diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4ae0f0b..ea479d4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import { getConnectionWorkbenchState } from './utils/startupReadiness'; import { toSaveGlobalProxyInput } from './utils/globalProxyDraft'; import { detectConnectionImportKind, + isConnectionPackagePasswordRequiredError, resolveConnectionPackageExportResult, normalizeConnectionPackagePassword, } from './utils/connectionExport'; @@ -120,6 +121,8 @@ type ConnectionPackageDialogMode = 'import' | 'export'; type ConnectionPackageDialogState = { open: boolean; mode: ConnectionPackageDialogMode; + includeSecrets: boolean; + useFilePassword: boolean; password: string; error: string; confirmLoading: boolean; @@ -128,6 +131,8 @@ type ConnectionPackageDialogState = { const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogState => ({ open: false, mode: 'export', + includeSecrets: true, + useFilePassword: false, password: '', error: '', confirmLoading: false, @@ -1476,22 +1481,24 @@ function App() { return; } - if (importKind === 'encrypted-package') { - setPendingConnectionImportPayload(raw); - setConnectionPackageDialog({ - open: true, - mode: 'import', - password: '', - error: '', - confirmLoading: false, - }); - return; - } - try { + setPendingConnectionImportPayload(null); const importedViews = await importConnectionsPayload(raw, ''); void message.success(`成功导入 ${importedViews.length} 个连接`); } catch (e: any) { + if (isConnectionPackagePasswordRequiredError(e)) { + setPendingConnectionImportPayload(raw); + setConnectionPackageDialog({ + open: true, + mode: 'import', + includeSecrets: true, + useFilePassword: false, + password: '', + error: '', + confirmLoading: false, + }); + return; + } void message.error(e?.message || '导入失败'); } }; @@ -1505,6 +1512,8 @@ function App() { setConnectionPackageDialog({ open: true, mode: 'export', + includeSecrets: true, + useFilePassword: false, password: '', error: '', confirmLoading: false, @@ -1515,7 +1524,7 @@ function App() { const backendApp = (window as any).go?.app?.App; const password = normalizeConnectionPackagePassword(connectionPackageDialog.password); - if (!password) { + if (connectionPackageDialog.mode === 'import' && !password) { setConnectionPackageDialog((current) => ({ ...current, error: '恢复包密码不能为空', @@ -1523,9 +1532,25 @@ function App() { return; } + if ( + connectionPackageDialog.mode === 'export' + && connectionPackageDialog.includeSecrets + && connectionPackageDialog.useFilePassword + && !password + ) { + setConnectionPackageDialog((current) => ({ + ...current, + error: '文件保护密码不能为空', + })); + return; + } + setConnectionPackageDialog((current) => ({ ...current, - password, + password: ( + current.mode === 'export' + && (!current.includeSecrets || !current.useFilePassword) + ) ? '' : password, error: '', confirmLoading: true, })); @@ -1536,7 +1561,13 @@ function App() { throw new Error('导出失败:当前后端未提供新版导出能力'); } - const res = await backendApp.ExportConnectionsPackage(password); + const res = await backendApp.ExportConnectionsPackage({ + includeSecrets: connectionPackageDialog.includeSecrets, + filePassword: ( + connectionPackageDialog.includeSecrets + && connectionPackageDialog.useFilePassword + ) ? password : '', + }); const exportResult = resolveConnectionPackageExportResult(connectionPackageDialog, res); if (exportResult.kind === 'canceled') { setConnectionPackageDialog(exportResult.nextDialog); @@ -2559,11 +2590,31 @@ function App() { /> { + setConnectionPackageDialog((current) => ({ + ...current, + includeSecrets: value, + useFilePassword: value ? current.useFilePassword : false, + password: value ? current.password : '', + error: '', + })); + }} + onUseFilePasswordChange={(value) => { + setConnectionPackageDialog((current) => ({ + ...current, + useFilePassword: value, + password: value ? current.password : '', + error: '', + })); + }} onPasswordChange={(value) => { setConnectionPackageDialog((current) => ({ ...current, diff --git a/frontend/src/components/ConnectionPackagePasswordModal.tsx b/frontend/src/components/ConnectionPackagePasswordModal.tsx index 2f415e1..0f42d29 100644 --- a/frontend/src/components/ConnectionPackagePasswordModal.tsx +++ b/frontend/src/components/ConnectionPackagePasswordModal.tsx @@ -1,16 +1,23 @@ import React from 'react'; -import { Input, Modal, Typography } from 'antd'; +import { Checkbox, Input, Modal, Typography } from 'antd'; const { Text } = Typography; +type ConnectionPackagePasswordModalMode = 'import' | 'export'; + export interface ConnectionPackagePasswordModalProps { open: boolean; title: string; + mode?: ConnectionPackagePasswordModalMode; + includeSecrets?: boolean; + useFilePassword?: boolean; password: string; error?: string; confirmLoading?: boolean; confirmText?: string; cancelText?: string; + onIncludeSecretsChange?: (value: boolean) => void; + onUseFilePasswordChange?: (value: boolean) => void; onPasswordChange: (value: string) => void; onConfirm: () => void; onCancel: () => void; @@ -19,15 +26,29 @@ export interface ConnectionPackagePasswordModalProps { export default function ConnectionPackagePasswordModal({ open, title, + mode = 'import', + includeSecrets = true, + useFilePassword = false, password, error, confirmLoading, confirmText = '确认', cancelText = '取消', + onIncludeSecretsChange, + onUseFilePasswordChange, onPasswordChange, onConfirm, onCancel, }: ConnectionPackagePasswordModalProps) { + const isExportMode = mode === 'export'; + const showFilePasswordInput = isExportMode ? useFilePassword : true; + const placeholder = isExportMode ? '请输入文件保护密码(可选)' : '请输入恢复包密码'; + const helperText = !includeSecrets + ? '将仅导出连接配置,不包含密码。' + : (useFilePassword + ? '请通过单独渠道将密码告知接收方,不要和文件一起发送。' + : '密码已加密保护。如需通过公网传输,建议设置文件保护密码。'); + return ( - onPasswordChange(event.target.value)} - /> + {isExportMode ? ( +
+ onIncludeSecretsChange?.(event.target.checked)} + > + 导出连接密码 + + onUseFilePasswordChange?.(event.target.checked)} + > + 设置文件保护密码 + +
+ ) : null} + {showFilePasswordInput ? ( + onPasswordChange(event.target.value)} + /> + ) : null} + {isExportMode ? ( + + {helperText} + + ) : null} {error ? ( {error} diff --git a/frontend/src/main.browserMock.test.ts b/frontend/src/main.browserMock.test.ts index 9d18204..7802732 100644 --- a/frontend/src/main.browserMock.test.ts +++ b/frontend/src/main.browserMock.test.ts @@ -51,8 +51,8 @@ const importMain = async () => { app?: { App?: { ImportConfigFile: () => Promise<{ success: boolean; message?: string }>; - ImportConnectionsPayload: (raw: string) => Promise; - ExportConnectionsPackage: () => Promise<{ success: boolean; message?: string }>; + ImportConnectionsPayload: (raw: string, password?: string) => Promise; + ExportConnectionsPackage: (options?: { includeSecrets?: boolean; filePassword?: string }) => Promise<{ success: boolean; message?: string }>; }; }; }; @@ -83,7 +83,7 @@ describe('main browser mock', () => { success: false, message: '已取消', }); - await expect(app!.ExportConnectionsPackage()).resolves.toEqual({ + await expect(app!.ExportConnectionsPackage({ includeSecrets: true, filePassword: '' })).resolves.toEqual({ success: false, message: '浏览器 mock 不支持恢复包导出', }); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index fea428b..392cd43 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -123,7 +123,7 @@ if (typeof window !== 'undefined' && !(window as any).go) { OpenDownloadedUpdateDirectory: async () => ({ success: false }), InstallUpdateAndRestart: async () => ({ success: false }), ImportConfigFile: async () => ({ success: false, message: '已取消' }), - ImportConnectionsPayload: async (raw: string) => { + ImportConnectionsPayload: async (raw: string, _password?: string) => { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { @@ -134,7 +134,7 @@ if (typeof window !== 'undefined' && !(window as any).go) { } throw new Error('浏览器 mock 不支持恢复包导入,仅支持历史 JSON 连接数组'); }, - ExportConnectionsPackage: async () => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }), + ExportConnectionsPackage: async (_options?: { includeSecrets?: boolean; filePassword?: string }) => ({ success: false, message: '浏览器 mock 不支持恢复包导出' }), ExportData: async () => ({ success: false }), GetGlobalProxyConfig: async () => ({ success: true, data: cloneBrowserMockValue(mockGlobalProxy) }), SaveGlobalProxy: async (input: any) => saveMockGlobalProxy(input), diff --git a/frontend/src/utils/connectionExport.test.ts b/frontend/src/utils/connectionExport.test.ts index d4f9720..d18541a 100644 --- a/frontend/src/utils/connectionExport.test.ts +++ b/frontend/src/utils/connectionExport.test.ts @@ -2,13 +2,64 @@ import { describe, expect, it } from 'vitest'; import { detectConnectionImportKind, + isConnectionPackagePasswordRequiredError, isConnectionPackageExportCanceled, resolveConnectionPackageExportResult, normalizeConnectionPackagePassword, } from './connectionExport'; describe('connectionExport', () => { - it('detects encrypted packages by gonavi envelope kind', () => { + it('detects v2 app-managed packages', () => { + expect(detectConnectionImportKind(JSON.stringify({ + v: 2, + kind: 'gonavi_connection_package', + p: 1, + exportedAt: '2026-04-11T21:00:00Z', + connections: [], + }))).toBe('app-managed-package'); + }); + + it('detects v2 encrypted packages', () => { + expect(detectConnectionImportKind(JSON.stringify({ + v: 2, + kind: 'gonavi_connection_package', + p: 2, + kdf: { + n: 'a2id', + m: 65536, + t: 3, + l: 4, + s: 'c2FsdA==', + }, + nc: 'bm9uY2Utbm9uY2U=', + d: 'encrypted-data', + }))).toBe('encrypted-package'); + }); + + it('rejects malformed v2 app-managed packages without connections array', () => { + expect(detectConnectionImportKind(JSON.stringify({ + v: 2, + kind: 'gonavi_connection_package', + p: 1, + exportedAt: '2026-04-11T21:00:00Z', + }))).toBe('invalid'); + }); + + it('rejects malformed v2 encrypted packages without protected payload fields', () => { + expect(detectConnectionImportKind(JSON.stringify({ + v: 2, + kind: 'gonavi_connection_package', + p: 2, + kdf: { + n: 'a2id', + m: 65536, + t: 3, + l: 4, + }, + }))).toBe('invalid'); + }); + + it('detects v1 encrypted packages by gonavi envelope kind', () => { expect(detectConnectionImportKind(JSON.stringify({ schemaVersion: 1, kind: 'gonavi_connection_package', @@ -39,6 +90,15 @@ describe('connectionExport', () => { it('returns invalid for malformed or unsupported content', () => { expect(detectConnectionImportKind('{not-json}')).toBe('invalid'); + expect(detectConnectionImportKind(JSON.stringify({ + v: 2, + kind: 'gonavi_connection_package', + p: 0, + }))).toBe('invalid'); + expect(detectConnectionImportKind(JSON.stringify({ + v: 2, + kind: 'gonavi_connection_package', + }))).toBe('invalid'); expect(detectConnectionImportKind(JSON.stringify({ kind: 'gonavi_connection_package', payload: 'encrypted-data', @@ -60,6 +120,14 @@ describe('connectionExport', () => { expect(normalizeConnectionPackagePassword('\n\t \t')).toBe(''); }); + it('recognizes backend password-required errors for protected packages', () => { + expect(isConnectionPackagePasswordRequiredError(new Error('恢复包密码不能为空'))).toBe(true); + expect(isConnectionPackagePasswordRequiredError({ message: '恢复包密码不能为空' })).toBe(true); + expect(isConnectionPackagePasswordRequiredError('恢复包密码不能为空')).toBe(true); + expect(isConnectionPackagePasswordRequiredError(new Error('文件密码错误或文件已损坏'))).toBe(false); + expect(isConnectionPackagePasswordRequiredError(undefined)).toBe(false); + }); + it('treats export cancel as a non-error backend result', () => { expect(isConnectionPackageExportCanceled({ success: false, message: '已取消' })).toBe(true); expect(isConnectionPackageExportCanceled({ success: false, message: '导出失败' })).toBe(false); @@ -71,6 +139,8 @@ describe('connectionExport', () => { const staleDialog = { open: true, mode: 'export' as const, + includeSecrets: true, + useFilePassword: false, password: ' secret-pass ', error: '上一次失败', confirmLoading: false, @@ -83,12 +153,16 @@ describe('connectionExport', () => { expect((canceledResult.nextDialog as (current: typeof staleDialog) => typeof staleDialog)({ open: false, mode: 'export', + includeSecrets: true, + useFilePassword: false, password: 'secret-pass', error: '更新后的错误', confirmLoading: true, })).toEqual({ open: false, mode: 'export', + includeSecrets: true, + useFilePassword: false, password: 'secret-pass', error: '', confirmLoading: false, diff --git a/frontend/src/utils/connectionExport.ts b/frontend/src/utils/connectionExport.ts index 13ff987..22de9eb 100644 --- a/frontend/src/utils/connectionExport.ts +++ b/frontend/src/utils/connectionExport.ts @@ -1,9 +1,11 @@ import type { ConnectionConfig, SavedConnection } from '../types'; -export type ConnectionImportKind = 'encrypted-package' | 'legacy-json' | 'invalid'; +export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'invalid'; export type ConnectionPackageDialogSnapshot = { open: boolean; mode: 'export' | 'import'; + includeSecrets: boolean; + useFilePassword: boolean; password: string; error: string; confirmLoading: boolean; @@ -20,7 +22,11 @@ export type ConnectionPackageExportResult = type JsonObject = Record; const CONNECTION_PACKAGE_KIND = 'gonavi_connection_package'; +const CONNECTION_PACKAGE_SCHEMA_VERSION_V2 = 2; +const CONNECTION_PACKAGE_PROTECTION_APP_MANAGED = 1; +const CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD = 2; const CANCELED_MESSAGE = '已取消'; +const CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE = '恢复包密码不能为空'; const isJsonObject = (value: unknown): value is JsonObject => ( typeof value === 'object' && value !== null && !Array.isArray(value) @@ -45,6 +51,36 @@ const isConnectionPackageEnvelope = (value: unknown): value is JsonObject => ( && typeof value.payload === 'string' ); +const isConnectionPackageV2Envelope = (value: unknown): value is JsonObject => ( + isJsonObject(value) + && value.kind === CONNECTION_PACKAGE_KIND + && value.v === CONNECTION_PACKAGE_SCHEMA_VERSION_V2 + && typeof value.p === 'number' +); + +const isConnectionPackageKDFV2 = (value: unknown): value is JsonObject => ( + isJsonObject(value) + && typeof value.n === 'string' + && typeof value.m === 'number' + && typeof value.t === 'number' + && typeof value.l === 'number' + && typeof value.s === 'string' +); + +const isConnectionPackageV2AppManagedEnvelope = (value: unknown): value is JsonObject => ( + isConnectionPackageV2Envelope(value) + && value.p === CONNECTION_PACKAGE_PROTECTION_APP_MANAGED + && Array.isArray(value.connections) +); + +const isConnectionPackageV2ProtectedEnvelope = (value: unknown): value is JsonObject => ( + isConnectionPackageV2Envelope(value) + && value.p === CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD + && isConnectionPackageKDFV2(value.kdf) + && typeof value.nc === 'string' + && typeof value.d === 'string' +); + const isLegacyConnectionConfig = (value: unknown): value is JsonObject => ( isJsonObject(value) && typeof value.type === 'string' @@ -72,6 +108,18 @@ const parseConnectionImportRaw = (raw: unknown): unknown => { export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => { const parsed = parseConnectionImportRaw(raw); + if (isConnectionPackageV2AppManagedEnvelope(parsed)) { + return 'app-managed-package'; + } + + if (isConnectionPackageV2ProtectedEnvelope(parsed)) { + return 'encrypted-package'; + } + + if (isConnectionPackageV2Envelope(parsed)) { + return 'invalid'; + } + if (Array.isArray(parsed) && parsed.every((item) => isLegacyConnectionItem(item))) { return 'legacy-json'; } @@ -85,6 +133,20 @@ export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind = export const normalizeConnectionPackagePassword = (value: string): string => value.trim(); +export const isConnectionPackagePasswordRequiredError = (value: unknown): boolean => { + if (typeof value === 'string') { + return value.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE; + } + + if (value instanceof Error) { + return value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE; + } + + return isJsonObject(value) + && typeof value.message === 'string' + && value.message.trim() === CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE; +}; + export const isConnectionPackageExportCanceled = (result: unknown): boolean => ( isJsonObject(result) && result.success === false diff --git a/frontend/wailsjs/go/app/App.d.ts b/frontend/wailsjs/go/app/App.d.ts index ab7f7d3..4f09f28 100755 --- a/frontend/wailsjs/go/app/App.d.ts +++ b/frontend/wailsjs/go/app/App.d.ts @@ -75,7 +75,7 @@ export function DuplicateConnection(arg1:string):Promise; -export function ExportConnectionsPackage(arg1:string):Promise; +export function ExportConnectionsPackage(arg1:app.ConnectionExportOptions):Promise; export function ExportData(arg1:Array>,arg2:Array,arg3:string,arg4:string):Promise; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index ca54253..9c899d1 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -181,6 +181,20 @@ export namespace ai { export namespace app { + export class ConnectionExportOptions { + includeSecrets: boolean; + filePassword?: string; + + static createFrom(source: any = {}) { + return new ConnectionExportOptions(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.includeSecrets = source["includeSecrets"]; + this.filePassword = source["filePassword"]; + } + } export class SecurityUpdateOptions { allowPartial?: boolean; writeBackup?: boolean; diff --git a/internal/app/connection_package_appkey.go b/internal/app/connection_package_appkey.go new file mode 100644 index 0000000..eb9e45f --- /dev/null +++ b/internal/app/connection_package_appkey.go @@ -0,0 +1,196 @@ +package app + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "strings" + "sync" + + "golang.org/x/crypto/argon2" +) + +const ( + connectionPackageAppKeyPurpose = "gonavi-export-key-v2" + connectionPackageAppKeyFallbackSeed = "gonavi-connection-package-v2-seed" + connectionPackageAppKeyFallbackSalt = "gonavi-connection-package-v2-salt" +) + +var ( + connectionPackageAppKeySeed string + connectionPackageAppKeySalt string + + connectionPackageAppKeyMu sync.Mutex + connectionPackageAppKeyCached []byte +) + +func deriveConnectionPackageAppKey() ([]byte, error) { + connectionPackageAppKeyMu.Lock() + defer connectionPackageAppKeyMu.Unlock() + + if len(connectionPackageAppKeyCached) == connectionPackageAES256KeyBytes { + return append([]byte(nil), connectionPackageAppKeyCached...), nil + } + + seed := strings.TrimSpace(connectionPackageAppKeySeed) + if seed == "" { + seed = connectionPackageAppKeyFallbackSeed + } + saltValue := strings.TrimSpace(connectionPackageAppKeySalt) + if saltValue == "" { + saltValue = connectionPackageAppKeyFallbackSalt + } + + mac := hmac.New(sha256.New, []byte(seed)) + if _, err := mac.Write([]byte(connectionPackageAppKeyPurpose)); err != nil { + return nil, err + } + intermediate := mac.Sum(nil) + + saltHash := sha256.Sum256([]byte(saltValue)) + key := argon2.IDKey( + intermediate, + saltHash[:connectionPackageSaltBytes], + connectionPackageKDFDefaultTimeCost, + connectionPackageKDFDefaultMemoryKiB, + connectionPackageKDFDefaultParallelism, + connectionPackageAES256KeyBytes, + ) + connectionPackageAppKeyCached = append([]byte(nil), key...) + return append([]byte(nil), key...), nil +} + +func resetConnectionPackageAppKeyCache() { + connectionPackageAppKeyMu.Lock() + defer connectionPackageAppKeyMu.Unlock() + connectionPackageAppKeyCached = nil +} + +func encryptSecretField(appKey []byte, plaintext string, aad string) (string, error) { + if plaintext == "" { + return "", nil + } + + aead, err := newConnectionPackageAEAD(appKey) + if err != nil { + return "", err + } + + nonce := make([]byte, connectionPackageNonceBytes) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + + ciphertext := aead.Seal(nil, nonce, []byte(plaintext), []byte(aad)) + encoded := make([]byte, 0, len(nonce)+len(ciphertext)) + encoded = append(encoded, nonce...) + encoded = append(encoded, ciphertext...) + return base64.StdEncoding.EncodeToString(encoded), nil +} + +func decryptSecretField(appKey []byte, encrypted string, aad string) (string, error) { + if encrypted == "" { + return "", nil + } + + raw, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + return "", err + } + if len(raw) <= connectionPackageNonceBytes { + return "", errors.New("invalid encrypted secret") + } + + aead, err := newConnectionPackageAEAD(appKey) + if err != nil { + return "", err + } + + plain, err := aead.Open(nil, raw[:connectionPackageNonceBytes], raw[connectionPackageNonceBytes:], []byte(aad)) + if err != nil { + return "", err + } + return string(plain), nil +} + +func encryptSecretBundle(appKey []byte, bundle connectionSecretBundle, connectionID string) (connectionSecretBundle, error) { + var encrypted connectionSecretBundle + var err error + + encrypted.Password, err = encryptSecretField(appKey, bundle.Password, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + encrypted.SSHPassword, err = encryptSecretField(appKey, bundle.SSHPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + encrypted.ProxyPassword, err = encryptSecretField(appKey, bundle.ProxyPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + encrypted.HTTPTunnelPassword, err = encryptSecretField(appKey, bundle.HTTPTunnelPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + encrypted.MySQLReplicaPassword, err = encryptSecretField(appKey, bundle.MySQLReplicaPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + encrypted.MongoReplicaPassword, err = encryptSecretField(appKey, bundle.MongoReplicaPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + encrypted.OpaqueURI, err = encryptSecretField(appKey, bundle.OpaqueURI, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + encrypted.OpaqueDSN, err = encryptSecretField(appKey, bundle.OpaqueDSN, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + + return encrypted, nil +} + +func decryptSecretBundle(appKey []byte, bundle connectionSecretBundle, connectionID string) (connectionSecretBundle, error) { + var decrypted connectionSecretBundle + var err error + + decrypted.Password, err = decryptSecretField(appKey, bundle.Password, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + decrypted.SSHPassword, err = decryptSecretField(appKey, bundle.SSHPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + decrypted.ProxyPassword, err = decryptSecretField(appKey, bundle.ProxyPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + decrypted.HTTPTunnelPassword, err = decryptSecretField(appKey, bundle.HTTPTunnelPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + decrypted.MySQLReplicaPassword, err = decryptSecretField(appKey, bundle.MySQLReplicaPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + decrypted.MongoReplicaPassword, err = decryptSecretField(appKey, bundle.MongoReplicaPassword, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + decrypted.OpaqueURI, err = decryptSecretField(appKey, bundle.OpaqueURI, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + decrypted.OpaqueDSN, err = decryptSecretField(appKey, bundle.OpaqueDSN, connectionID) + if err != nil { + return connectionSecretBundle{}, err + } + + return decrypted, nil +} diff --git a/internal/app/connection_package_appkey_test.go b/internal/app/connection_package_appkey_test.go new file mode 100644 index 0000000..619bb84 --- /dev/null +++ b/internal/app/connection_package_appkey_test.go @@ -0,0 +1,141 @@ +package app + +import ( + "encoding/base64" + "reflect" + "strings" + "testing" +) + +func TestDeriveConnectionPackageAppKeyIsStable(t *testing.T) { + originalSeed := connectionPackageAppKeySeed + originalSalt := connectionPackageAppKeySalt + t.Cleanup(func() { + connectionPackageAppKeySeed = originalSeed + connectionPackageAppKeySalt = originalSalt + resetConnectionPackageAppKeyCache() + }) + + connectionPackageAppKeySeed = "unit-test-seed" + connectionPackageAppKeySalt = "unit-test-salt" + resetConnectionPackageAppKeyCache() + + first, err := deriveConnectionPackageAppKey() + if err != nil { + t.Fatalf("deriveConnectionPackageAppKey returned error: %v", err) + } + second, err := deriveConnectionPackageAppKey() + if err != nil { + t.Fatalf("deriveConnectionPackageAppKey returned error on second call: %v", err) + } + if len(first) != connectionPackageAES256KeyBytes { + t.Fatalf("expected %d-byte app key, got %d", connectionPackageAES256KeyBytes, len(first)) + } + if !reflect.DeepEqual(first, second) { + t.Fatal("expected deriveConnectionPackageAppKey to be stable across repeated calls") + } + + connectionPackageAppKeySeed = "unit-test-seed-rotated" + resetConnectionPackageAppKeyCache() + rotated, err := deriveConnectionPackageAppKey() + if err != nil { + t.Fatalf("deriveConnectionPackageAppKey returned error after seed rotation: %v", err) + } + if reflect.DeepEqual(first, rotated) { + t.Fatal("expected different injected seed to produce a different app key") + } +} + +func TestEncryptSecretFieldRoundTrip(t *testing.T) { + appKey := []byte("0123456789abcdef0123456789abcdef") + + encrypted, err := encryptSecretField(appKey, "super-secret", "conn-1") + if err != nil { + t.Fatalf("encryptSecretField returned error: %v", err) + } + if strings.HasPrefix(encrypted, "ENC:") { + t.Fatalf("encrypted field must not carry ENC prefix, got %q", encrypted) + } + raw, err := base64.StdEncoding.DecodeString(encrypted) + if err != nil { + t.Fatalf("encrypted field must be base64, got error: %v", err) + } + if len(raw) <= connectionPackageNonceBytes { + t.Fatalf("expected nonce+ciphertext output, got %d bytes", len(raw)) + } + + decrypted, err := decryptSecretField(appKey, encrypted, "conn-1") + if err != nil { + t.Fatalf("decryptSecretField returned error: %v", err) + } + if decrypted != "super-secret" { + t.Fatalf("round-trip mismatch: got %q", decrypted) + } +} + +func TestDecryptSecretFieldRejectsAADMismatch(t *testing.T) { + appKey := []byte("0123456789abcdef0123456789abcdef") + + encrypted, err := encryptSecretField(appKey, "super-secret", "conn-1") + if err != nil { + t.Fatalf("encryptSecretField returned error: %v", err) + } + + if _, err := decryptSecretField(appKey, encrypted, "conn-2"); err == nil { + t.Fatal("expected decryptSecretField to reject mismatched AAD") + } +} + +func TestEncryptSecretBundleRoundTripAndAADBinding(t *testing.T) { + appKey := []byte("0123456789abcdef0123456789abcdef") + plain := connectionSecretBundle{ + Password: "primary-secret", + SSHPassword: "ssh-secret", + ProxyPassword: "proxy-secret", + HTTPTunnelPassword: "http-secret", + MySQLReplicaPassword: "mysql-secret", + MongoReplicaPassword: "mongo-secret", + OpaqueURI: "postgres://user:pass@db.local/app", + OpaqueDSN: "server=db.local;password=secret", + } + + encrypted, err := encryptSecretBundle(appKey, plain, "conn-1") + if err != nil { + t.Fatalf("encryptSecretBundle returned error: %v", err) + } + + for name, value := range map[string]string{ + "password": encrypted.Password, + "sshPassword": encrypted.SSHPassword, + "proxyPassword": encrypted.ProxyPassword, + "httpTunnelPassword": encrypted.HTTPTunnelPassword, + "mysqlReplicaPassword": encrypted.MySQLReplicaPassword, + "mongoReplicaPassword": encrypted.MongoReplicaPassword, + "opaqueURI": encrypted.OpaqueURI, + "opaqueDSN": encrypted.OpaqueDSN, + } { + if value == "" { + t.Fatalf("expected encrypted %s field to be populated", name) + } + if strings.HasPrefix(value, "ENC:") { + t.Fatalf("encrypted %s field must not carry ENC prefix", name) + } + if value == plain.Password || value == plain.SSHPassword || value == plain.ProxyPassword || + value == plain.HTTPTunnelPassword || value == plain.MySQLReplicaPassword || value == plain.MongoReplicaPassword || + value == plain.OpaqueURI || value == plain.OpaqueDSN { + t.Fatalf("expected encrypted %s field to differ from plaintext", name) + } + } + + decrypted, err := decryptSecretBundle(appKey, encrypted, "conn-1") + if err != nil { + t.Fatalf("decryptSecretBundle returned error: %v", err) + } + if !reflect.DeepEqual(decrypted, plain) { + t.Fatalf("bundle round-trip mismatch: got=%+v want=%+v", decrypted, plain) + } + + if _, err := decryptSecretBundle(appKey, encrypted, "conn-2"); err == nil { + t.Fatal("expected decryptSecretBundle to reject mismatched connection AAD") + } +} diff --git a/internal/app/connection_package_crypto.go b/internal/app/connection_package_crypto.go index 8337e2e..1b7b748 100644 --- a/internal/app/connection_package_crypto.go +++ b/internal/app/connection_package_crypto.go @@ -26,6 +26,14 @@ type connectionPackageAAD struct { Nonce string `json:"nonce"` } +type connectionPackageAADV2Protected struct { + V int `json:"v"` + Kind string `json:"kind"` + P int `json:"p"` + KDF connectionPackageKDFSpecV2 `json:"kdf"` + NC string `json:"nc"` +} + func encryptConnectionPackage(payload connectionPackagePayload, password string) (connectionPackageFile, error) { normalizedPassword := normalizeConnectionPackagePassword(password) if normalizedPassword == "" { @@ -108,7 +116,162 @@ func isConnectionPackageEnvelope(raw string) bool { if err != nil { return false } - return file.Kind == connectionPackageKind + return validateConnectionPackageFileHeader(file) == nil +} + +func encryptConnectionPackageV2AppManaged(payload connectionPackagePayload) (connectionPackageFileV2, error) { + appKey, err := deriveConnectionPackageAppKey() + if err != nil { + return connectionPackageFileV2{}, err + } + + encryptedPayload, err := encryptConnectionPackagePayloadSecrets(payload, appKey) + if err != nil { + return connectionPackageFileV2{}, err + } + + return connectionPackageFileV2{ + V: connectionPackageSchemaVersionV2, + Kind: connectionPackageKind, + P: connectionPackageProtectionAppManaged, + ExportedAt: encryptedPayload.ExportedAt, + Connections: encryptedPayload.Connections, + }, nil +} + +func encryptConnectionPackageV2Protected(payload connectionPackagePayload, password string) (connectionPackageFileV2Protected, error) { + normalizedPassword := normalizeConnectionPackagePassword(password) + if normalizedPassword == "" { + return connectionPackageFileV2Protected{}, errConnectionPackagePasswordRequired + } + + appKey, err := deriveConnectionPackageAppKey() + if err != nil { + return connectionPackageFileV2Protected{}, err + } + encryptedPayload, err := encryptConnectionPackagePayloadSecrets(payload, appKey) + if err != nil { + return connectionPackageFileV2Protected{}, err + } + + plain, err := json.Marshal(encryptedPayload) + if err != nil { + return connectionPackageFileV2Protected{}, err + } + + salt := make([]byte, connectionPackageSaltBytes) + if _, err := rand.Read(salt); err != nil { + return connectionPackageFileV2Protected{}, err + } + nonce := make([]byte, connectionPackageNonceBytes) + if _, err := rand.Read(nonce); err != nil { + return connectionPackageFileV2Protected{}, err + } + + file := connectionPackageFileV2Protected{ + V: connectionPackageSchemaVersionV2, + Kind: connectionPackageKind, + P: connectionPackageProtectionPasswordProtected, + KDF: defaultConnectionPackageKDFSpecV2(), + NC: base64.StdEncoding.EncodeToString(nonce), + } + file.KDF.S = base64.StdEncoding.EncodeToString(salt) + + key, err := deriveConnectionPackageKeyV2(normalizedPassword, file.KDF) + if err != nil { + return connectionPackageFileV2Protected{}, err + } + aad, err := marshalConnectionPackageAADV2Protected(file) + if err != nil { + return connectionPackageFileV2Protected{}, err + } + aead, err := newConnectionPackageAEAD(key) + if err != nil { + return connectionPackageFileV2Protected{}, err + } + + ciphertext := aead.Seal(nil, nonce, plain, aad) + if len(ciphertext) > connectionPackageMaxCiphertextBytes { + return connectionPackageFileV2Protected{}, errConnectionPackagePayloadTooLarge + } + file.D = base64.StdEncoding.EncodeToString(ciphertext) + if len(file.D) > connectionPackageMaxPayloadBase64Bytes { + return connectionPackageFileV2Protected{}, errConnectionPackagePayloadTooLarge + } + return file, nil +} + +func decryptConnectionPackageV2AppManaged(file connectionPackageFileV2) (connectionPackagePayload, error) { + if err := validateConnectionPackageFileHeaderV2AppManaged(file); err != nil { + return connectionPackagePayload{}, err + } + + appKey, err := deriveConnectionPackageAppKey() + if err != nil { + return connectionPackagePayload{}, err + } + + payload, err := decryptConnectionPackagePayloadSecrets(connectionPackagePayload{ + ExportedAt: file.ExportedAt, + Connections: file.Connections, + }, appKey) + if err != nil { + return connectionPackagePayload{}, errConnectionPackageDecryptFailed + } + return payload, nil +} + +func decryptConnectionPackageV2Protected(file connectionPackageFileV2Protected, password string) (connectionPackagePayload, error) { + normalizedPassword := normalizeConnectionPackagePassword(password) + if normalizedPassword == "" { + return connectionPackagePayload{}, errConnectionPackagePasswordRequired + } + if err := validateConnectionPackageFileHeaderV2Protected(file); err != nil { + return connectionPackagePayload{}, err + } + + plain, err := decryptConnectionPackageV2ProtectedPlaintext(file, normalizedPassword) + if err != nil { + if errors.Is(err, errConnectionPackagePayloadTooLarge) { + return connectionPackagePayload{}, err + } + return connectionPackagePayload{}, errConnectionPackageDecryptFailed + } + + var encryptedPayload connectionPackagePayload + if err := json.Unmarshal(plain, &encryptedPayload); err != nil { + return connectionPackagePayload{}, errConnectionPackageDecryptFailed + } + + appKey, err := deriveConnectionPackageAppKey() + if err != nil { + return connectionPackagePayload{}, err + } + payload, err := decryptConnectionPackagePayloadSecrets(encryptedPayload, appKey) + if err != nil { + return connectionPackagePayload{}, errConnectionPackageDecryptFailed + } + return payload, nil +} + +func isConnectionPackageV2AppManaged(raw string) bool { + header, err := decodeConnectionPackageV2Header(raw) + if err != nil { + return false + } + return header.Kind == connectionPackageKind && + header.V == connectionPackageSchemaVersionV2 && + header.P == connectionPackageProtectionAppManaged +} + +func isConnectionPackageV2Protected(raw string) bool { + header, err := decodeConnectionPackageV2Header(raw) + if err != nil { + return false + } + return header.Kind == connectionPackageKind && + header.V == connectionPackageSchemaVersionV2 && + header.P == connectionPackageProtectionPasswordProtected } func encodeConnectionPackageEnvelope(file connectionPackageFile) (string, error) { @@ -127,6 +290,22 @@ func decodeConnectionPackageEnvelope(raw string) (connectionPackageFile, error) return file, nil } +func decodeConnectionPackageV2Header(raw string) (struct { + V int `json:"v"` + Kind string `json:"kind"` + P int `json:"p"` +}, error) { + var header struct { + V int `json:"v"` + Kind string `json:"kind"` + P int `json:"p"` + } + if err := json.Unmarshal([]byte(raw), &header); err != nil { + return header, err + } + return header, nil +} + func decryptConnectionPackagePlaintext(file connectionPackageFile, password string) ([]byte, error) { if err := validateConnectionPackageFileHeader(file); err != nil { return nil, err @@ -191,6 +370,30 @@ func deriveConnectionPackageKey(password string, spec connectionPackageKDFSpec) return key, nil } +func deriveConnectionPackageKeyV2(password string, spec connectionPackageKDFSpecV2) ([]byte, error) { + if password == "" { + return nil, errConnectionPackagePasswordRequired + } + if err := validateConnectionPackageKDFSpecV2(spec); err != nil { + return nil, err + } + + salt, err := base64.StdEncoding.DecodeString(spec.S) + if err != nil || len(salt) == 0 { + return nil, errors.New("invalid salt") + } + + key := argon2.IDKey( + []byte(password), + salt, + spec.T, + spec.M, + spec.L, + connectionPackageAES256KeyBytes, + ) + return key, nil +} + func marshalConnectionPackageAAD(file connectionPackageFile) ([]byte, error) { aad := connectionPackageAAD{ SchemaVersion: file.SchemaVersion, @@ -202,6 +405,16 @@ func marshalConnectionPackageAAD(file connectionPackageFile) ([]byte, error) { return json.Marshal(aad) } +func marshalConnectionPackageAADV2Protected(file connectionPackageFileV2Protected) ([]byte, error) { + return json.Marshal(connectionPackageAADV2Protected{ + V: file.V, + Kind: file.Kind, + P: file.P, + KDF: file.KDF, + NC: file.NC, + }) +} + func newConnectionPackageAEAD(key []byte) (cipher.AEAD, error) { block, err := aes.NewCipher(key) if err != nil { @@ -225,6 +438,34 @@ func validateConnectionPackageFileHeader(file connectionPackageFile) error { } } +func validateConnectionPackageFileHeaderV2AppManaged(file connectionPackageFileV2) error { + switch { + case file.V != connectionPackageSchemaVersionV2: + return errConnectionPackageUnsupported + case strings.TrimSpace(file.Kind) != connectionPackageKind: + return errConnectionPackageUnsupported + case file.P != connectionPackageProtectionAppManaged: + return errConnectionPackageUnsupported + default: + return nil + } +} + +func validateConnectionPackageFileHeaderV2Protected(file connectionPackageFileV2Protected) error { + switch { + case file.V != connectionPackageSchemaVersionV2: + return errConnectionPackageUnsupported + case strings.TrimSpace(file.Kind) != connectionPackageKind: + return errConnectionPackageUnsupported + case file.P != connectionPackageProtectionPasswordProtected: + return errConnectionPackageUnsupported + case validateConnectionPackageKDFSpecV2(file.KDF) != nil: + return errConnectionPackageUnsupported + default: + return nil + } +} + func validateConnectionPackageKDFSpec(spec connectionPackageKDFSpec) error { switch { case strings.TrimSpace(spec.Name) != connectionPackageKDFName: @@ -241,3 +482,101 @@ func validateConnectionPackageKDFSpec(spec connectionPackageKDFSpec) error { return nil } } + +func validateConnectionPackageKDFSpecV2(spec connectionPackageKDFSpecV2) error { + switch { + case strings.TrimSpace(spec.N) != connectionPackageKDFNameV2: + return errConnectionPackageUnsupported + case spec.M == 0 || spec.T == 0 || spec.L == 0: + return errConnectionPackageUnsupported + case spec.M > connectionPackageKDFMaxMemoryKiB: + return errConnectionPackageUnsupported + case spec.T > connectionPackageKDFMaxTimeCost: + return errConnectionPackageUnsupported + case spec.L > connectionPackageKDFMaxParallelism: + return errConnectionPackageUnsupported + default: + return nil + } +} + +func decryptConnectionPackageV2ProtectedPlaintext(file connectionPackageFileV2Protected, password string) ([]byte, error) { + if err := validateConnectionPackageFileHeaderV2Protected(file); err != nil { + return nil, err + } + + nonce, err := base64.StdEncoding.DecodeString(file.NC) + if err != nil || len(nonce) != connectionPackageNonceBytes { + return nil, errors.New("invalid nonce") + } + if len(file.D) > connectionPackageMaxPayloadBase64Bytes { + return nil, errConnectionPackagePayloadTooLarge + } + ciphertext, err := base64.StdEncoding.DecodeString(file.D) + if err != nil || len(ciphertext) == 0 { + return nil, errors.New("invalid payload") + } + if len(ciphertext) > connectionPackageMaxCiphertextBytes { + return nil, errConnectionPackagePayloadTooLarge + } + + key, err := deriveConnectionPackageKeyV2(password, file.KDF) + if err != nil { + return nil, err + } + aad, err := marshalConnectionPackageAADV2Protected(file) + if err != nil { + return nil, err + } + aead, err := newConnectionPackageAEAD(key) + if err != nil { + return nil, err + } + + return aead.Open(nil, nonce, ciphertext, aad) +} + +func encryptConnectionPackagePayloadSecrets(payload connectionPackagePayload, appKey []byte) (connectionPackagePayload, error) { + encrypted := connectionPackagePayload{ + ExportedAt: payload.ExportedAt, + Connections: make([]connectionPackageItem, len(payload.Connections)), + } + + for index, item := range payload.Connections { + encryptedItem := item + bundle, err := encryptSecretBundle(appKey, item.Secrets, connectionPackageItemAAD(item)) + if err != nil { + return connectionPackagePayload{}, err + } + encryptedItem.Secrets = bundle + encrypted.Connections[index] = encryptedItem + } + + return encrypted, nil +} + +func decryptConnectionPackagePayloadSecrets(payload connectionPackagePayload, appKey []byte) (connectionPackagePayload, error) { + decrypted := connectionPackagePayload{ + ExportedAt: payload.ExportedAt, + Connections: make([]connectionPackageItem, len(payload.Connections)), + } + + for index, item := range payload.Connections { + decryptedItem := item + bundle, err := decryptSecretBundle(appKey, item.Secrets, connectionPackageItemAAD(item)) + if err != nil { + return connectionPackagePayload{}, err + } + decryptedItem.Secrets = bundle + decrypted.Connections[index] = decryptedItem + } + + return decrypted, nil +} + +func connectionPackageItemAAD(item connectionPackageItem) string { + if strings.TrimSpace(item.ID) != "" { + return item.ID + } + return item.Config.ID +} diff --git a/internal/app/connection_package_crypto_test.go b/internal/app/connection_package_crypto_test.go index 22ba2f1..57d3748 100644 --- a/internal/app/connection_package_crypto_test.go +++ b/internal/app/connection_package_crypto_test.go @@ -59,6 +59,186 @@ func TestConnectionPackageCryptoRoundTrip(t *testing.T) { } } +func TestConnectionPackageV2AppManagedRoundTrip(t *testing.T) { + payload := connectionPackagePayload{ + ExportedAt: "2026-04-11T12:00:00Z", + Connections: []connectionPackageItem{ + { + ID: "conn-v2-1", + Name: "app-managed", + Config: connection.ConnectionConfig{ + ID: "conn-v2-1", + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "postgres", + Database: "app", + }, + Secrets: connectionSecretBundle{ + Password: "primary-secret", + SSHPassword: "ssh-secret", + OpaqueURI: "postgres://postgres:primary-secret@db.local/app", + }, + }, + }, + } + + file, err := encryptConnectionPackageV2AppManaged(payload) + if err != nil { + t.Fatalf("encryptConnectionPackageV2AppManaged returned error: %v", err) + } + if file.V != connectionPackageSchemaVersionV2 { + t.Fatalf("expected v2 schema, got %d", file.V) + } + if file.P != connectionPackageProtectionAppManaged { + t.Fatalf("expected p=1, got %d", file.P) + } + if len(file.Connections) != 1 { + t.Fatalf("expected 1 connection, got %d", len(file.Connections)) + } + if file.Connections[0].Secrets.Password == payload.Connections[0].Secrets.Password { + t.Fatal("expected p=1 secrets to stay encrypted in file") + } + + raw, err := json.Marshal(file) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + if !isConnectionPackageV2AppManaged(string(raw)) { + t.Fatal("expected raw v2 p=1 payload to be detected") + } + if isConnectionPackageEnvelope(string(raw)) { + t.Fatal("v2 p=1 payload must not be misclassified as v1 envelope") + } + rawString := string(raw) + for _, forbidden := range []string{ + "schemaVersion", + "cipher", + "protectionLevel", + "ENC:", + "primary-secret", + "ssh-secret", + "postgres://postgres:primary-secret@db.local/app", + } { + if strings.Contains(rawString, forbidden) { + t.Fatalf("v2 p=1 payload must not contain %q: %s", forbidden, rawString) + } + } + + got, err := decryptConnectionPackageV2AppManaged(file) + if err != nil { + t.Fatalf("decryptConnectionPackageV2AppManaged returned error: %v", err) + } + if !reflect.DeepEqual(got, payload) { + t.Fatalf("round-trip mismatch: got=%+v want=%+v", got, payload) + } +} + +func TestConnectionPackageV2ProtectedRoundTrip(t *testing.T) { + payload := connectionPackagePayload{ + ExportedAt: "2026-04-11T12:00:00Z", + Connections: []connectionPackageItem{ + { + ID: "conn-v2-2", + Name: "password-protected", + Config: connection.ConnectionConfig{ + ID: "conn-v2-2", + Type: "mysql", + Host: "db.local", + Port: 3306, + User: "root", + Database: "app", + }, + Secrets: connectionSecretBundle{ + Password: "primary-secret", + SSHPassword: "ssh-secret", + ProxyPassword: "proxy-secret", + HTTPTunnelPassword: "http-secret", + MySQLReplicaPassword: "mysql-secret", + MongoReplicaPassword: "mongo-secret", + OpaqueURI: "mysql://root:primary-secret@tcp(db.local:3306)/app", + OpaqueDSN: "root:primary-secret@tcp(db.local:3306)/app", + }, + }, + }, + } + + file, err := encryptConnectionPackageV2Protected(payload, "package-password") + if err != nil { + t.Fatalf("encryptConnectionPackageV2Protected returned error: %v", err) + } + if file.V != connectionPackageSchemaVersionV2 { + t.Fatalf("expected v2 schema, got %d", file.V) + } + if file.P != connectionPackageProtectionPasswordProtected { + t.Fatalf("expected p=2, got %d", file.P) + } + if file.D == "" || file.NC == "" { + t.Fatal("expected p=2 file to carry outer encrypted payload") + } + if strings.HasPrefix(file.D, "ENC:") { + t.Fatalf("outer payload must not carry ENC prefix, got %q", file.D) + } + + raw, err := json.Marshal(file) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + if !isConnectionPackageV2Protected(string(raw)) { + t.Fatal("expected raw v2 p=2 payload to be detected") + } + if isConnectionPackageEnvelope(string(raw)) { + t.Fatal("v2 p=2 payload must not be misclassified as v1 envelope") + } + rawString := string(raw) + for _, forbidden := range []string{ + "schemaVersion", + "cipher", + "protectionLevel", + "ENC:", + "primary-secret", + "ssh-secret", + } { + if strings.Contains(rawString, forbidden) { + t.Fatalf("v2 p=2 payload must not contain %q: %s", forbidden, rawString) + } + } + + got, err := decryptConnectionPackageV2Protected(file, "package-password") + if err != nil { + t.Fatalf("decryptConnectionPackageV2Protected returned error: %v", err) + } + if !reflect.DeepEqual(got, payload) { + t.Fatalf("round-trip mismatch: got=%+v want=%+v", got, payload) + } +} + +func TestConnectionPackageV2ProtectedWrongPasswordReturnsUnifiedError(t *testing.T) { + file, err := encryptConnectionPackageV2Protected(connectionPackagePayload{ + Connections: []connectionPackageItem{ + { + ID: "conn-v2-3", + Name: "wrong-password", + Config: connection.ConnectionConfig{ + ID: "conn-v2-3", + Type: "postgres", + }, + Secrets: connectionSecretBundle{ + Password: "primary-secret", + }, + }, + }, + }, "correct-password") + if err != nil { + t.Fatalf("encryptConnectionPackageV2Protected returned error: %v", err) + } + + _, err = decryptConnectionPackageV2Protected(file, "wrong-password") + if !errors.Is(err, errConnectionPackageDecryptFailed) { + t.Fatalf("wrong p=2 password should return unified error, got: %v", err) + } +} + func TestConnectionPackageDecryptWrongPasswordReturnsUnifiedError(t *testing.T) { payload := connectionPackagePayload{ Connections: []connectionPackageItem{ diff --git a/internal/app/connection_package_transfer.go b/internal/app/connection_package_transfer.go index 3fc8e31..efba78a 100644 --- a/internal/app/connection_package_transfer.go +++ b/internal/app/connection_package_transfer.go @@ -48,6 +48,34 @@ func (a *App) buildConnectionPackagePayload() (connectionPackagePayload, error) }, nil } +func (a *App) buildExportedConnectionPackage(options ConnectionExportOptions) ([]byte, error) { + payload, err := a.buildConnectionPackagePayload() + if err != nil { + return nil, err + } + + if !options.IncludeSecrets { + for index := range payload.Connections { + payload.Connections[index].Secrets = connectionSecretBundle{} + } + } + + normalizedPassword := normalizeConnectionPackagePassword(options.FilePassword) + if !options.IncludeSecrets || normalizedPassword == "" { + file, err := encryptConnectionPackageV2AppManaged(payload) + if err != nil { + return nil, err + } + return json.MarshalIndent(file, "", " ") + } + + file, err := encryptConnectionPackageV2Protected(payload, normalizedPassword) + if err != nil { + return nil, err + } + return json.MarshalIndent(file, "", " ") +} + func newSavedConnectionInputFromPackageItem(item connectionPackageItem) connection.SavedConnectionInput { id := strings.TrimSpace(item.ID) if id == "" { @@ -192,6 +220,30 @@ func (a *App) ImportConnectionsPayload(raw string, password string) ([]connectio return nil, errConnectionImportFileTooLarge } + if isConnectionPackageV2AppManaged(trimmed) { + var file connectionPackageFileV2 + if err := json.Unmarshal([]byte(trimmed), &file); err != nil { + return nil, errConnectionPackageUnsupported + } + payload, err := decryptConnectionPackageV2AppManaged(file) + if err != nil { + return nil, err + } + return a.importConnectionPackagePayload(payload) + } + + if isConnectionPackageV2Protected(trimmed) { + var file connectionPackageFileV2Protected + if err := json.Unmarshal([]byte(trimmed), &file); err != nil { + return nil, errConnectionPackageUnsupported + } + payload, err := decryptConnectionPackageV2Protected(file, password) + if err != nil { + return nil, err + } + return a.importConnectionPackagePayload(payload) + } + if isConnectionPackageEnvelope(trimmed) { var file connectionPackageFile if err := json.Unmarshal([]byte(trimmed), &file); err != nil { diff --git a/internal/app/connection_package_transfer_test.go b/internal/app/connection_package_transfer_test.go index 81d40ea..0c11d3e 100644 --- a/internal/app/connection_package_transfer_test.go +++ b/internal/app/connection_package_transfer_test.go @@ -83,6 +83,71 @@ func TestBuildConnectionPackagePayloadIncludesSecretBundles(t *testing.T) { } } +func TestBuildExportedConnectionPackageWithoutSecretsUsesV2AppManagedAndImportsWithoutPasswords(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + + _, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: "conn-v2-no-secrets", + Name: "Primary", + Config: connection.ConnectionConfig{ + ID: "conn-v2-no-secrets", + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "postgres", + Password: "db-secret", + }, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } + + raw, err := app.buildExportedConnectionPackage(ConnectionExportOptions{ + IncludeSecrets: false, + FilePassword: "ignored-password", + }) + if err != nil { + t.Fatalf("buildExportedConnectionPackage returned error: %v", err) + } + + var file connectionPackageFileV2 + if err := json.Unmarshal(raw, &file); err != nil { + t.Fatalf("json.Unmarshal returned error: %v", err) + } + if file.V != connectionPackageSchemaVersionV2 { + t.Fatalf("expected v2 package, got v=%d", file.V) + } + if file.P != connectionPackageProtectionAppManaged { + t.Fatalf("expected app-managed protection, got p=%d", file.P) + } + if strings.Contains(string(raw), `"secrets"`) { + t.Fatalf("expected exported JSON to omit secrets when IncludeSecrets=false, got %s", string(raw)) + } + + importApp := NewAppWithSecretStore(newFakeAppSecretStore()) + importApp.configDir = t.TempDir() + + imported, err := importApp.ImportConnectionsPayload(string(raw), "") + if err != nil { + t.Fatalf("ImportConnectionsPayload returned error: %v", err) + } + if len(imported) != 1 { + t.Fatalf("expected 1 imported connection, got %d", len(imported)) + } + if imported[0].HasPrimaryPassword { + t.Fatal("expected imported connection to keep empty password when secrets are excluded") + } + + resolved, err := importApp.resolveConnectionSecrets(imported[0].Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets returned error: %v", err) + } + if resolved.Password != "" { + t.Fatalf("expected imported password to be empty, got %q", resolved.Password) + } +} + func TestImportConnectionPackagePayloadOverwritesExistingSecrets(t *testing.T) { app := NewAppWithSecretStore(newFakeAppSecretStore()) app.configDir = t.TempDir() @@ -792,6 +857,93 @@ func TestImportConnectionsPayloadEnvelopeImportsAndOverwritesSecrets(t *testing. } } +func TestBuildExportedConnectionPackageWithSecretsUsesV2AppManagedEncryption(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + saveConnectionForPackageExport(t, app, "conn-v2-app", "app-secret") + + raw, err := app.buildExportedConnectionPackage(ConnectionExportOptions{ + IncludeSecrets: true, + }) + if err != nil { + t.Fatalf("buildExportedConnectionPackage returned error: %v", err) + } + + rawString := string(raw) + if !isConnectionPackageV2AppManaged(rawString) { + t.Fatalf("expected app-managed export, got %s", rawString) + } + for _, forbidden := range []string{ + "app-secret", + "schemaVersion", + "cipher", + "ENC:", + } { + if strings.Contains(rawString, forbidden) { + t.Fatalf("v2 p=1 export must not contain %q: %s", forbidden, rawString) + } + } + + imported, err := app.ImportConnectionsPayload(rawString, "") + if err != nil { + t.Fatalf("ImportConnectionsPayload returned error: %v", err) + } + if len(imported) != 1 { + t.Fatalf("expected 1 imported item, got %d", len(imported)) + } + + resolved, err := app.resolveConnectionSecrets(imported[0].Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets returned error: %v", err) + } + if resolved.Password != "app-secret" { + t.Fatalf("expected v2 p=1 import to restore password, got %q", resolved.Password) + } +} + +func TestBuildExportedConnectionPackageWithFilePasswordUsesV2ProtectedEnvelope(t *testing.T) { + app := NewAppWithSecretStore(newFakeAppSecretStore()) + app.configDir = t.TempDir() + saveConnectionForPackageExport(t, app, "conn-v2-protected", "protected-secret") + + raw, err := app.buildExportedConnectionPackage(ConnectionExportOptions{ + IncludeSecrets: true, + FilePassword: "package-password", + }) + if err != nil { + t.Fatalf("buildExportedConnectionPackage returned error: %v", err) + } + + rawString := string(raw) + if !isConnectionPackageV2Protected(rawString) { + t.Fatalf("expected password-protected export, got %s", rawString) + } + if strings.Contains(rawString, "protected-secret") { + t.Fatalf("v2 p=2 export must not contain plaintext secret: %s", rawString) + } + + _, err = app.ImportConnectionsPayload(rawString, "wrong-password") + if !errors.Is(err, errConnectionPackageDecryptFailed) { + t.Fatalf("wrong v2 p=2 password should return unified error, got %v", err) + } + + imported, err := app.ImportConnectionsPayload(rawString, "package-password") + if err != nil { + t.Fatalf("ImportConnectionsPayload returned error: %v", err) + } + if len(imported) != 1 { + t.Fatalf("expected 1 imported item, got %d", len(imported)) + } + + resolved, err := app.resolveConnectionSecrets(imported[0].Config) + if err != nil { + t.Fatalf("resolveConnectionSecrets returned error: %v", err) + } + if resolved.Password != "protected-secret" { + t.Fatalf("expected v2 p=2 import to restore password, got %q", resolved.Password) + } +} + func TestNormalizeConnectionPackageExportFilenameAddsExtension(t *testing.T) { filename := normalizeConnectionPackageExportFilename(`C:\tmp\connections`) if !strings.HasSuffix(filename, connectionPackageExtension) { @@ -816,6 +968,34 @@ func newFailOnPutSecretStore(failRef string) *failOnPutSecretStore { } } +func saveConnectionForPackageExport(t *testing.T, app *App, id string, primaryPassword string) { + t.Helper() + + _, err := app.SaveConnection(connection.SavedConnectionInput{ + ID: id, + Name: "Exported " + id, + Config: connection.ConnectionConfig{ + ID: id, + Type: "postgres", + Host: "db.local", + Port: 5432, + User: "postgres", + Password: primaryPassword, + UseSSH: true, + SSH: connection.SSHConfig{ + Host: "jump.local", + Port: 22, + User: "ops", + Password: "ssh-" + primaryPassword, + }, + URI: "postgres://postgres:" + primaryPassword + "@db.local/app", + }, + }) + if err != nil { + t.Fatalf("SaveConnection returned error: %v", err) + } +} + func (s *failOnPutSecretStore) Put(ref string, payload []byte) error { if ref == s.failRef { return errors.New("injected put failure") diff --git a/internal/app/connection_package_types.go b/internal/app/connection_package_types.go index 18b28b6..3794656 100644 --- a/internal/app/connection_package_types.go +++ b/internal/app/connection_package_types.go @@ -1,6 +1,7 @@ package app import ( + "encoding/json" "errors" "strings" @@ -8,11 +9,16 @@ import ( ) const ( - connectionPackageSchemaVersion = 1 - connectionPackageKind = "gonavi_connection_package" - connectionPackageCipher = "AES-256-GCM" - connectionPackageKDFName = "Argon2id" - connectionPackageExtension = ".gonavi-conn" + connectionPackageSchemaVersion = 1 + connectionPackageSchemaVersionV2 = 2 + connectionPackageKind = "gonavi_connection_package" + connectionPackageCipher = "AES-256-GCM" + connectionPackageKDFName = "Argon2id" + connectionPackageKDFNameV2 = "a2id" + connectionPackageExtension = ".gonavi-conn" + + connectionPackageProtectionAppManaged = 1 + connectionPackageProtectionPasswordProtected = 2 connectionPackageKDFDefaultMemoryKiB = 65536 connectionPackageKDFDefaultTimeCost = 3 @@ -53,6 +59,31 @@ type connectionPackageKDFSpec struct { Salt string `json:"salt"` } +type connectionPackageFileV2 struct { + V int `json:"v"` + Kind string `json:"kind"` + P int `json:"p"` + ExportedAt string `json:"exportedAt,omitempty"` + Connections []connectionPackageItem `json:"connections"` +} + +type connectionPackageFileV2Protected struct { + V int `json:"v"` + Kind string `json:"kind"` + P int `json:"p"` + KDF connectionPackageKDFSpecV2 `json:"kdf"` + NC string `json:"nc"` + D string `json:"d"` +} + +type connectionPackageKDFSpecV2 struct { + N string `json:"n"` + M uint32 `json:"m"` + T uint32 `json:"t"` + L uint8 `json:"l"` + S string `json:"s"` +} + type connectionPackagePayload struct { ExportedAt string `json:"exportedAt,omitempty"` Connections []connectionPackageItem `json:"connections"` @@ -69,6 +100,39 @@ type connectionPackageItem struct { Secrets connectionSecretBundle `json:"secrets,omitempty"` } +func (i connectionPackageItem) MarshalJSON() ([]byte, error) { + type connectionPackageItemJSON struct { + ID string `json:"id"` + Name string `json:"name"` + IncludeDatabases []string `json:"includeDatabases,omitempty"` + IncludeRedisDatabases []int `json:"includeRedisDatabases,omitempty"` + IconType string `json:"iconType,omitempty"` + IconColor string `json:"iconColor,omitempty"` + Config connection.ConnectionConfig `json:"config"` + Secrets *connectionSecretBundle `json:"secrets,omitempty"` + } + + item := connectionPackageItemJSON{ + ID: i.ID, + Name: i.Name, + IncludeDatabases: i.IncludeDatabases, + IncludeRedisDatabases: i.IncludeRedisDatabases, + IconType: i.IconType, + IconColor: i.IconColor, + Config: i.Config, + } + if i.Secrets.hasAny() { + secrets := i.Secrets + item.Secrets = &secrets + } + return json.Marshal(item) +} + +type ConnectionExportOptions struct { + IncludeSecrets bool `json:"includeSecrets"` + FilePassword string `json:"filePassword,omitempty"` +} + func defaultConnectionPackageKDFSpec() connectionPackageKDFSpec { return connectionPackageKDFSpec{ Name: connectionPackageKDFName, @@ -78,6 +142,15 @@ func defaultConnectionPackageKDFSpec() connectionPackageKDFSpec { } } +func defaultConnectionPackageKDFSpecV2() connectionPackageKDFSpecV2 { + return connectionPackageKDFSpecV2{ + N: connectionPackageKDFNameV2, + M: connectionPackageKDFDefaultMemoryKiB, + T: connectionPackageKDFDefaultTimeCost, + L: connectionPackageKDFDefaultParallelism, + } +} + func normalizeConnectionPackagePassword(password string) string { return strings.TrimSpace(password) } diff --git a/internal/app/methods_file.go b/internal/app/methods_file.go index 924730a..0d1c312 100644 --- a/internal/app/methods_file.go +++ b/internal/app/methods_file.go @@ -306,12 +306,7 @@ func (a *App) ImportConfigFile() connection.QueryResult { return connection.QueryResult{Success: true, Data: content} } -func (a *App) ExportConnectionsPackage(password string) connection.QueryResult { - payload, err := a.buildConnectionPackagePayload() - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - +func (a *App) ExportConnectionsPackage(options ConnectionExportOptions) connection.QueryResult { filename, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "Export Connections", DefaultFilename: "connections" + connectionPackageExtension, @@ -327,12 +322,7 @@ func (a *App) ExportConnectionsPackage(password string) connection.QueryResult { } filename = normalizeConnectionPackageExportFilename(filename) - pkg, err := encryptConnectionPackage(payload, password) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - - content, err := json.MarshalIndent(pkg, "", " ") + content, err := a.buildExportedConnectionPackage(options) if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} }