feat(connection-package): 支持连接恢复包双模式加密导入导出

- 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路
- 扩展前后端导入导出流程并兼容 v1 与 legacy 格式
- 修复无文件密码恢复包导入误弹密码框导致的流程阻塞
This commit is contained in:
tianqijiuyun-latiao
2026-04-11 23:51:43 +08:00
parent 1751e14d20
commit 52d2ee7592
16 changed files with 1447 additions and 49 deletions

View File

@@ -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() {
/>
<ConnectionPackagePasswordModal
open={connectionPackageDialog.open}
title={connectionPackageDialog.mode === 'export' ? '输入导出密码' : '输入导入密码'}
title={connectionPackageDialog.mode === 'export' ? '导出连接' : '输入导入密码'}
mode={connectionPackageDialog.mode}
includeSecrets={connectionPackageDialog.includeSecrets}
useFilePassword={connectionPackageDialog.useFilePassword}
password={connectionPackageDialog.password}
error={connectionPackageDialog.error}
confirmLoading={connectionPackageDialog.confirmLoading}
confirmText={connectionPackageDialog.mode === 'export' ? '开始导出' : '开始导入'}
onIncludeSecretsChange={(value) => {
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,

View File

@@ -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 (
<Modal
open={open}
@@ -40,12 +61,37 @@ export default function ConnectionPackagePasswordModal({
destroyOnClose={false}
maskClosable={false}
>
<Input.Password
autoFocus
value={password}
placeholder="请输入恢复包密码"
onChange={(event) => onPasswordChange(event.target.value)}
/>
{isExportMode ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Checkbox
checked={includeSecrets}
onChange={(event) => onIncludeSecretsChange?.(event.target.checked)}
>
</Checkbox>
<Checkbox
checked={useFilePassword}
disabled={!includeSecrets}
onChange={(event) => onUseFilePasswordChange?.(event.target.checked)}
>
</Checkbox>
</div>
) : null}
{showFilePasswordInput ? (
<Input.Password
autoFocus
value={password}
placeholder={placeholder}
disabled={isExportMode && !useFilePassword}
onChange={(event) => onPasswordChange(event.target.value)}
/>
) : null}
{isExportMode ? (
<Text type={useFilePassword ? 'warning' : 'secondary'} style={{ display: 'block', marginTop: 8 }}>
{helperText}
</Text>
) : null}
{error ? (
<Text type="danger" style={{ display: 'block', marginTop: 8 }}>
{error}

View File

@@ -51,8 +51,8 @@ const importMain = async () => {
app?: {
App?: {
ImportConfigFile: () => Promise<{ success: boolean; message?: string }>;
ImportConnectionsPayload: (raw: string) => Promise<unknown>;
ExportConnectionsPackage: () => Promise<{ success: boolean; message?: string }>;
ImportConnectionsPayload: (raw: string, password?: string) => Promise<unknown>;
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 不支持恢复包导出',
});

View File

@@ -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),

View File

@@ -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,

View File

@@ -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<string, unknown>;
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

View File

@@ -75,7 +75,7 @@ export function DuplicateConnection(arg1:string):Promise<connection.SavedConnect
export function ExecuteSQLFile(arg1:connection.ConnectionConfig,arg2:string,arg3:string,arg4:string):Promise<connection.QueryResult>;
export function ExportConnectionsPackage(arg1:string):Promise<connection.QueryResult>;
export function ExportConnectionsPackage(arg1:app.ConnectionExportOptions):Promise<connection.QueryResult>;
export function ExportData(arg1:Array<Record<string, any>>,arg2:Array<string>,arg3:string,arg4:string):Promise<connection.QueryResult>;

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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{

View File

@@ -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 {

View File

@@ -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")

View File

@@ -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)
}

View File

@@ -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()}
}