Renames the product across the codebase: Go module path, config/data paths, keychain service name, export file extension, build tooling, docs, and UI text. Sidebar brand mark now reads ">_ AlchementaryLink" with "Alchementary" in the connection-group gold accent colour, and the window title is "Alchementary Link". Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019T8bJS8BCtY5GeSG2HBj1n
210 lines
5.9 KiB
Go
210 lines
5.9 KiB
Go
// Package exportimport provides encrypted profile export and import.
|
|
// Profiles are serialised to JSON, then encrypted with AES-256-GCM using
|
|
// an Argon2id-derived key from a user-supplied password. No hard-coded
|
|
// secrets are used. The file format includes a version byte, salt, nonce,
|
|
// and ciphertext — everything needed for self-contained decryption.
|
|
package exportimport
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/argon2"
|
|
|
|
"github.com/andydixon/link/internal/models"
|
|
)
|
|
|
|
const (
|
|
// File format version. Increment if the envelope format changes.
|
|
formatVersion byte = 1
|
|
|
|
// Argon2id parameters for export encryption (same strength as the vault).
|
|
exportArgonTime = 3
|
|
exportArgonMemory = 128 * 1024 // 128 MiB
|
|
exportArgonThreads = 4
|
|
exportArgonKeyLen = 32 // AES-256
|
|
|
|
saltLength = 32
|
|
nonceLength = 12 // AES-GCM standard nonce size
|
|
|
|
maxEncryptedImportBytes = 10 * 1024 * 1024
|
|
)
|
|
|
|
// ExportBundle is the cleartext payload that gets encrypted.
|
|
type ExportBundle struct {
|
|
Version int `json:"version"`
|
|
ExportedAt string `json:"exportedAt"` // ISO 8601
|
|
Profiles []models.Profile `json:"profiles"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// EncryptAndExport serialises the given profiles, encrypts the JSON with
|
|
// AES-256-GCM (Argon2id-derived key), and writes the result to the file.
|
|
//
|
|
// File layout: [1 byte version][32 bytes salt][12 bytes nonce][ciphertext]
|
|
func EncryptAndExport(profiles []models.Profile, password, filePath string) error {
|
|
if password == "" {
|
|
return errors.New("exportimport: password cannot be empty")
|
|
}
|
|
if len(profiles) == 0 {
|
|
return errors.New("exportimport: no profiles to export")
|
|
}
|
|
|
|
bundle := ExportBundle{
|
|
Version: 1,
|
|
ExportedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Profiles: profiles,
|
|
Count: len(profiles),
|
|
}
|
|
|
|
plaintext, err := json.MarshalIndent(bundle, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("exportimport: failed to serialise profiles: %w", err)
|
|
}
|
|
defer clear(plaintext)
|
|
|
|
// Generate random salt and nonce.
|
|
salt := make([]byte, saltLength)
|
|
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
|
|
return fmt.Errorf("exportimport: failed to generate salt: %w", err)
|
|
}
|
|
|
|
nonce := make([]byte, nonceLength)
|
|
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
return fmt.Errorf("exportimport: failed to generate nonce: %w", err)
|
|
}
|
|
|
|
// Derive encryption key from password.
|
|
passwordBytes := []byte(password)
|
|
defer clear(passwordBytes)
|
|
key := argon2.IDKey(
|
|
passwordBytes,
|
|
salt,
|
|
exportArgonTime,
|
|
exportArgonMemory,
|
|
exportArgonThreads,
|
|
exportArgonKeyLen,
|
|
)
|
|
defer clear(key)
|
|
|
|
// Encrypt with AES-256-GCM.
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return fmt.Errorf("exportimport: cipher init failed: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return fmt.Errorf("exportimport: GCM init failed: %w", err)
|
|
}
|
|
|
|
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
|
|
|
|
// Write the file: version + salt + nonce + ciphertext.
|
|
f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("exportimport: failed to create file: %w", err)
|
|
}
|
|
defer f.Close()
|
|
if err := f.Chmod(0600); err != nil {
|
|
return fmt.Errorf("exportimport: failed to restrict file permissions: %w", err)
|
|
}
|
|
|
|
if _, err := f.Write([]byte{formatVersion}); err != nil {
|
|
return fmt.Errorf("exportimport: write version failed: %w", err)
|
|
}
|
|
if _, err := f.Write(salt); err != nil {
|
|
return fmt.Errorf("exportimport: write salt failed: %w", err)
|
|
}
|
|
if _, err := f.Write(nonce); err != nil {
|
|
return fmt.Errorf("exportimport: write nonce failed: %w", err)
|
|
}
|
|
if _, err := f.Write(ciphertext); err != nil {
|
|
return fmt.Errorf("exportimport: write ciphertext failed: %w", err)
|
|
}
|
|
|
|
return f.Sync()
|
|
}
|
|
|
|
// DecryptAndImport reads an encrypted export file, decrypts it using the
|
|
// given password, and returns the contained profiles.
|
|
func DecryptAndImport(filePath, password string) ([]models.Profile, error) {
|
|
if password == "" {
|
|
return nil, errors.New("exportimport: password cannot be empty")
|
|
}
|
|
|
|
info, err := os.Stat(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("exportimport: failed to stat file: %w", err)
|
|
}
|
|
if info.Size() > maxEncryptedImportBytes {
|
|
return nil, fmt.Errorf("exportimport: file is too large (max %d bytes)", maxEncryptedImportBytes)
|
|
}
|
|
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("exportimport: failed to read file: %w", err)
|
|
}
|
|
|
|
// Minimum size: version(1) + salt(32) + nonce(12) + at least 1 byte ciphertext + GCM tag(16).
|
|
minSize := 1 + saltLength + nonceLength + 1 + 16
|
|
if len(data) < minSize {
|
|
return nil, errors.New("exportimport: file is too small or corrupt")
|
|
}
|
|
|
|
// Parse envelope.
|
|
version := data[0]
|
|
if version != formatVersion {
|
|
return nil, fmt.Errorf("exportimport: unsupported format version %d", version)
|
|
}
|
|
|
|
salt := data[1 : 1+saltLength]
|
|
nonce := data[1+saltLength : 1+saltLength+nonceLength]
|
|
ciphertext := data[1+saltLength+nonceLength:]
|
|
|
|
// Derive decryption key.
|
|
passwordBytes := []byte(password)
|
|
defer clear(passwordBytes)
|
|
key := argon2.IDKey(
|
|
passwordBytes,
|
|
salt,
|
|
exportArgonTime,
|
|
exportArgonMemory,
|
|
exportArgonThreads,
|
|
exportArgonKeyLen,
|
|
)
|
|
defer clear(key)
|
|
|
|
// Decrypt with AES-256-GCM.
|
|
block, err := aes.NewCipher(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("exportimport: cipher init failed: %w", err)
|
|
}
|
|
|
|
gcm, err := cipher.NewGCM(block)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("exportimport: GCM init failed: %w", err)
|
|
}
|
|
|
|
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
|
if err != nil {
|
|
return nil, errors.New("exportimport: decryption failed — wrong password or corrupt file")
|
|
}
|
|
defer clear(plaintext)
|
|
|
|
// Parse the JSON bundle.
|
|
var bundle ExportBundle
|
|
if err := json.Unmarshal(plaintext, &bundle); err != nil {
|
|
return nil, fmt.Errorf("exportimport: invalid bundle JSON: %w", err)
|
|
}
|
|
|
|
return bundle.Profiles, nil
|
|
}
|