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

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