// 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_signing_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 signing 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 }