package app import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/json" "errors" "strings" "golang.org/x/crypto/argon2" ) const ( connectionPackageAES256KeyBytes = 32 connectionPackageSaltBytes = 16 connectionPackageNonceBytes = 12 ) type connectionPackageAAD struct { SchemaVersion int `json:"schemaVersion"` Kind string `json:"kind"` Cipher string `json:"cipher"` KDF connectionPackageKDFSpec `json:"kdf"` 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 == "" { return connectionPackageFile{}, errConnectionPackagePasswordRequired } plain, err := json.Marshal(payload) if err != nil { return connectionPackageFile{}, err } salt := make([]byte, connectionPackageSaltBytes) if _, err := rand.Read(salt); err != nil { return connectionPackageFile{}, err } nonce := make([]byte, connectionPackageNonceBytes) if _, err := rand.Read(nonce); err != nil { return connectionPackageFile{}, err } file := connectionPackageFile{ SchemaVersion: connectionPackageSchemaVersion, Kind: connectionPackageKind, Cipher: connectionPackageCipher, KDF: defaultConnectionPackageKDFSpec(), Nonce: base64.StdEncoding.EncodeToString(nonce), } file.KDF.Salt = base64.StdEncoding.EncodeToString(salt) key, err := deriveConnectionPackageKey(normalizedPassword, file.KDF) if err != nil { return connectionPackageFile{}, err } aad, err := marshalConnectionPackageAAD(file) if err != nil { return connectionPackageFile{}, err } aead, err := newConnectionPackageAEAD(key) if err != nil { return connectionPackageFile{}, err } ciphertext := aead.Seal(nil, nonce, plain, aad) if len(ciphertext) > connectionPackageMaxCiphertextBytes { return connectionPackageFile{}, errConnectionPackagePayloadTooLarge } file.Payload = base64.StdEncoding.EncodeToString(ciphertext) if len(file.Payload) > connectionPackageMaxPayloadBase64Bytes { return connectionPackageFile{}, errConnectionPackagePayloadTooLarge } return file, nil } func decryptConnectionPackage(file connectionPackageFile, password string) (connectionPackagePayload, error) { normalizedPassword := normalizeConnectionPackagePassword(password) if normalizedPassword == "" { return connectionPackagePayload{}, errConnectionPackagePasswordRequired } if err := validateConnectionPackageFileHeader(file); err != nil { return connectionPackagePayload{}, err } plain, err := decryptConnectionPackagePlaintext(file, normalizedPassword) if err != nil { if errors.Is(err, errConnectionPackagePayloadTooLarge) { return connectionPackagePayload{}, err } return connectionPackagePayload{}, errConnectionPackageDecryptFailed } var payload connectionPackagePayload if err := json.Unmarshal(plain, &payload); err != nil { return connectionPackagePayload{}, errConnectionPackageDecryptFailed } return payload, nil } func isConnectionPackageEnvelope(raw string) bool { file, err := decodeConnectionPackageEnvelope(raw) if err != nil { return false } 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) { raw, err := json.Marshal(file) if err != nil { return "", err } return string(raw), nil } func decodeConnectionPackageEnvelope(raw string) (connectionPackageFile, error) { var file connectionPackageFile if err := json.Unmarshal([]byte(raw), &file); err != nil { return connectionPackageFile{}, err } 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 } nonce, err := base64.StdEncoding.DecodeString(file.Nonce) if err != nil || len(nonce) != connectionPackageNonceBytes { return nil, errors.New("invalid nonce") } if len(file.Payload) > connectionPackageMaxPayloadBase64Bytes { return nil, errConnectionPackagePayloadTooLarge } ciphertext, err := base64.StdEncoding.DecodeString(file.Payload) if err != nil || len(ciphertext) == 0 { return nil, errors.New("invalid payload") } if len(ciphertext) > connectionPackageMaxCiphertextBytes { return nil, errConnectionPackagePayloadTooLarge } key, err := deriveConnectionPackageKey(password, file.KDF) if err != nil { return nil, err } aad, err := marshalConnectionPackageAAD(file) if err != nil { return nil, err } aead, err := newConnectionPackageAEAD(key) if err != nil { return nil, err } plain, err := aead.Open(nil, nonce, ciphertext, aad) if err != nil { return nil, err } return plain, nil } func deriveConnectionPackageKey(password string, spec connectionPackageKDFSpec) ([]byte, error) { if password == "" { return nil, errConnectionPackagePasswordRequired } if err := validateConnectionPackageKDFSpec(spec); err != nil { return nil, err } salt, err := base64.StdEncoding.DecodeString(spec.Salt) if err != nil || len(salt) == 0 { return nil, errors.New("invalid salt") } key := argon2.IDKey( []byte(password), salt, spec.TimeCost, spec.MemoryKiB, spec.Parallelism, connectionPackageAES256KeyBytes, ) 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, Kind: file.Kind, Cipher: file.Cipher, KDF: file.KDF, Nonce: file.Nonce, } 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 { return nil, err } return cipher.NewGCM(block) } func validateConnectionPackageFileHeader(file connectionPackageFile) error { switch { case file.SchemaVersion != connectionPackageSchemaVersion: return errConnectionPackageUnsupported case strings.TrimSpace(file.Kind) != connectionPackageKind: return errConnectionPackageUnsupported case strings.TrimSpace(file.Cipher) != connectionPackageCipher: return errConnectionPackageUnsupported case validateConnectionPackageKDFSpec(file.KDF) != nil: return errConnectionPackageUnsupported default: return nil } } 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: return errConnectionPackageUnsupported case spec.MemoryKiB == 0 || spec.TimeCost == 0 || spec.Parallelism == 0: return errConnectionPackageUnsupported case spec.MemoryKiB > connectionPackageKDFMaxMemoryKiB: return errConnectionPackageUnsupported case spec.TimeCost > connectionPackageKDFMaxTimeCost: return errConnectionPackageUnsupported case spec.Parallelism > connectionPackageKDFMaxParallelism: return errConnectionPackageUnsupported default: 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 }