diff options
| author | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
|---|---|---|
| committer | Gab Virebent <gabriel1@virebent.art> | 2026-08-22 20:36:56 +0200 |
| commit | c1decadb590c4d79d92bcc4df7772119a5c91244 (patch) | |
| tree | 468f9fa37535dbb90cc5d4061eb20de83912131e /internal/profile/vault.go | |
| download | aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.gz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.tar.xz aegis-c1decadb590c4d79d92bcc4df7772119a5c91244.zip | |
Initial Aegis Usenet client release
Diffstat (limited to 'internal/profile/vault.go')
| -rw-r--r-- | internal/profile/vault.go | 235 |
1 files changed, 235 insertions, 0 deletions
diff --git a/internal/profile/vault.go b/internal/profile/vault.go new file mode 100644 index 0000000..554d01b --- /dev/null +++ b/internal/profile/vault.go @@ -0,0 +1,235 @@ +// Package profile stores the optional VFace identity in an age-encrypted vault. +package profile + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "aegis/internal/cryptokit" + "filippo.io/age" + agearmor "filippo.io/age/armor" +) + +const ( + Version = 1 + MinPasswordLength = 12 + maxVaultBytes = 1 << 20 +) + +type Profile struct { + Version int `json:"version"` + Username string `json:"username"` + Email string `json:"email"` + PublicKey string `json:"ed25519_public_key"` + PrivateKey string `json:"ed25519_private_key"` + AgeIdentity string `json:"age_identity,omitempty"` + YubiKeyFingerprint string `json:"yubikey_openpgp_fingerprint,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func DefaultPath() (string, error) { + directory, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate profile directory: %w", err) + } + return filepath.Join(directory, "aegis", "profile.json.age"), nil +} + +func Generate(username, email string) (Profile, error) { + username = strings.TrimSpace(username) + email = strings.TrimSpace(email) + if username == "" || email == "" { + return Profile{}, errors.New("VFace username and email are required") + } + if strings.ContainsAny(username+email, "\x00\r\n|") { + return Profile{}, errors.New("VFace username and email contain forbidden characters") + } + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return Profile{}, fmt.Errorf("generate VFace Ed25519 key pair: %w", err) + } + return Profile{ + Version: Version, + Username: username, + Email: email, + PublicKey: base64.StdEncoding.EncodeToString(publicKey), + PrivateKey: base64.StdEncoding.EncodeToString(privateKey), + CreatedAt: time.Now().UTC(), + }, nil +} + +func Save(path string, value Profile, password string) error { + if err := validatePassword(password); err != nil { + return err + } + if err := Validate(value); err != nil { + return err + } + plaintext, err := json.MarshalIndent(value, "", " ") + if err != nil { + return fmt.Errorf("encode VFace profile: %w", err) + } + recipient, err := age.NewScryptRecipient(password) + if err != nil { + return fmt.Errorf("create profile password recipient: %w", err) + } + var encrypted bytes.Buffer + armor := agearmor.NewWriter(&encrypted) + writer, err := age.Encrypt(armor, recipient) + if err != nil { + _ = armor.Close() + return fmt.Errorf("create profile vault: %w", err) + } + if _, err := writer.Write(plaintext); err != nil { + _ = writer.Close() + _ = armor.Close() + return fmt.Errorf("write profile vault: %w", err) + } + if err := writer.Close(); err != nil { + _ = armor.Close() + return fmt.Errorf("close profile vault: %w", err) + } + if err := armor.Close(); err != nil { + return fmt.Errorf("close profile armor: %w", err) + } + return atomicWrite(path, encrypted.Bytes()) +} + +func Load(path string, password string) (Profile, error) { + if err := validatePassword(password); err != nil { + return Profile{}, err + } + ciphertext, err := os.ReadFile(path) + if err != nil { + return Profile{}, fmt.Errorf("read profile vault: %w", err) + } + if len(ciphertext) > maxVaultBytes { + return Profile{}, errors.New("profile vault is too large") + } + identity, err := age.NewScryptIdentity(password) + if err != nil { + return Profile{}, fmt.Errorf("create profile password identity: %w", err) + } + reader, err := age.Decrypt(agearmor.NewReader(bytes.NewReader(ciphertext)), identity) + if err != nil { + return Profile{}, errors.New("cannot unlock profile vault") + } + plaintext, err := io.ReadAll(io.LimitReader(reader, maxVaultBytes+1)) + if err != nil { + return Profile{}, fmt.Errorf("read profile vault: %w", err) + } + if len(plaintext) > maxVaultBytes { + return Profile{}, errors.New("profile vault contents are too large") + } + var value Profile + if err := json.Unmarshal(plaintext, &value); err != nil { + return Profile{}, errors.New("profile vault contains invalid data") + } + if err := Validate(value); err != nil { + return Profile{}, fmt.Errorf("validate profile vault: %w", err) + } + return value, nil +} + +func Validate(value Profile) error { + if value.Version != Version { + return fmt.Errorf("unsupported VFace profile version %d", value.Version) + } + if value.Username == "" || value.Email == "" || strings.ContainsAny(value.Username+value.Email, "\x00\r\n|") { + return errors.New("invalid VFace username or email") + } + publicKey, err := decodePublicKey(value.PublicKey) + if err != nil { + return err + } + if strings.TrimSpace(value.PrivateKey) == "" { + if strings.TrimSpace(value.YubiKeyFingerprint) == "" { + return errors.New("profile needs an encrypted private key or a YubiKey OpenPGP fingerprint") + } + return nil + } + privateKey, err := decodePrivateKey(value.PrivateKey) + if err != nil { + return err + } + derived := privateKey.Public().(ed25519.PublicKey) + if !bytes.Equal(publicKey, derived) { + return errors.New("VFace public and private keys do not match") + } + return nil +} + +func validatePassword(password string) error { + if len([]rune(password)) < MinPasswordLength { + return fmt.Errorf("profile password must contain at least %d characters", MinPasswordLength) + } + return nil +} + +func decodePublicKey(value string) (ed25519.PublicKey, error) { + canonical, err := cryptokit.CanonicalEd25519PublicKey(value) + if err != nil { + return nil, fmt.Errorf("invalid VFace public key: %w", err) + } + decoded, err := base64.StdEncoding.DecodeString(canonical) + if err != nil { + return nil, errors.New("invalid VFace public key encoding") + } + return ed25519.PublicKey(decoded), nil +} + +func decodePrivateKey(value string) (ed25519.PrivateKey, error) { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(value)) + if err != nil || len(decoded) != ed25519.PrivateKeySize { + return nil, errors.New("invalid VFace private key") + } + return ed25519.PrivateKey(decoded), nil +} + +func atomicWrite(path string, data []byte) error { + if path == "" { + return errors.New("profile vault path is required") + } + directory := filepath.Dir(path) + if err := os.MkdirAll(directory, 0o700); err != nil { + return fmt.Errorf("create profile directory: %w", err) + } + if err := os.Chmod(directory, 0o700); err != nil { + return fmt.Errorf("protect profile directory: %w", err) + } + temporary, err := os.CreateTemp(directory, ".profile-*.tmp") + if err != nil { + return fmt.Errorf("create profile temporary file: %w", err) + } + temporaryName := temporary.Name() + defer os.Remove(temporaryName) + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return err + } + if _, err := temporary.Write(data); err != nil { + _ = temporary.Close() + return fmt.Errorf("write profile vault: %w", err) + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return fmt.Errorf("sync profile vault: %w", err) + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryName, path); err != nil { + return fmt.Errorf("commit profile vault: %w", err) + } + return nil +} |
