summaryrefslogtreecommitdiffstats
path: root/internal/profile
diff options
context:
space:
mode:
Diffstat (limited to 'internal/profile')
-rw-r--r--internal/profile/vault.go235
-rw-r--r--internal/profile/vault_test.go45
2 files changed, 280 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
+}
diff --git a/internal/profile/vault_test.go b/internal/profile/vault_test.go
new file mode 100644
index 0000000..a726c5f
--- /dev/null
+++ b/internal/profile/vault_test.go
@@ -0,0 +1,45 @@
+package profile
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestVaultRoundTrip(t *testing.T) {
+ value, err := Generate("pseudonym", "reader@example.org")
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(t.TempDir(), "profile.json.age")
+ if err := Save(path, value, "correct horse battery staple"); err != nil {
+ t.Fatal(err)
+ }
+ loaded, err := Load(path, "correct horse battery staple")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Username != value.Username || loaded.PublicKey != value.PublicKey || loaded.PrivateKey != value.PrivateKey {
+ t.Fatal("profile vault round-trip changed identity")
+ }
+ if _, err := Load(path, "wrong password"); err == nil {
+ t.Fatal("wrong password unlocked profile vault")
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(data) == 0 || string(data) == "" {
+ t.Fatal("empty profile vault")
+ }
+}
+
+func TestPasswordMinimum(t *testing.T) {
+ value, err := Generate("pseudonym", "reader@example.org")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := Save(filepath.Join(t.TempDir(), "profile.json.age"), value, "short"); err == nil {
+ t.Fatal("short profile password accepted")
+ }
+}