mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 01:11:31 +08:00
✨ feat(connection-package): 支持连接恢复包双模式加密导入导出
- 新增 v2 连接恢复包 appKey 与文件密码双模式加密链路 - 扩展前后端导入导出流程并兼容 v1 与 legacy 格式 - 修复无文件密码恢复包导入误弹密码框导致的流程阻塞
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 不支持恢复包导出',
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
196
internal/app/connection_package_appkey.go
Normal file
196
internal/app/connection_package_appkey.go
Normal 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
|
||||
}
|
||||
141
internal/app/connection_package_appkey_test.go
Normal file
141
internal/app/connection_package_appkey_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user