diff --git a/frontend/src/pages/SecurityKeyManagement.tsx b/frontend/src/pages/SecurityKeyManagement.tsx index 5a445a8..c6cbc11 100644 --- a/frontend/src/pages/SecurityKeyManagement.tsx +++ b/frontend/src/pages/SecurityKeyManagement.tsx @@ -1,93 +1,102 @@ +import { useEffect, useState } from 'react'; import toast from 'react-hot-toast'; -import { KeyRound, ShieldCheck, ShieldAlert, Lock } from 'lucide-react'; -import Button from '@/components/bits/Button'; -import { useAuth } from '@/context/AuthContext'; +import { Lock } from 'lucide-react'; +import Toggle from '@/components/bits/Toggle'; +import ConfirmDialog from '@/components/bits/ConfirmDialog'; +import { toErrorMessage } from '@/lib/errors'; +import { GetMasterKeyStorage, SetMasterKeyStorage } from '../../wailsjs/go/auth/Service'; + +type MasterKeyStorage = 'database' | 'keyring'; export default function SecurityKeyManagement() { - const { session } = useAuth(); + const [storage, setStorage] = useState(null); + const [pendingStorage, setPendingStorage] = useState(null); + + const loadStorage = async () => { + try { + const current = await GetMasterKeyStorage(); + setStorage(current === 'keyring' ? 'keyring' : 'database'); + } catch (error) { + console.error('Failed to load master key storage:', error); + } + }; + + useEffect(() => { + loadStorage(); + }, []); - const handleDownloadRecoveryKey = () => { - toast('Your recovery key is only shown once, right after registration or password reset.'); + const handleToggle = (checked: boolean) => { + setPendingStorage(checked ? 'keyring' : 'database'); }; - const keys = [ - { - icon: , - title: 'Master Key', - description: - 'Derived from your password and used to unlock everything. Never stored on disk.', - status: 'Derived in-memory', - ok: true, - }, - { - icon: , - title: 'Recovery Key', - description: - 'Backs up your database credentials. Shown once at registration and after a password reset.', - status: 'Shown once', - ok: false, - }, - { - icon: , - title: 'Key Encryption Key (KEK)', - description: 'Encrypts your stored database credentials with password and recovery keys.', - status: 'Active', - ok: true, - }, - ]; + const handleConfirm = async () => { + const target = pendingStorage; + setPendingStorage(null); + if (!target || target === storage) return; + + try { + const result = await SetMasterKeyStorage(target); + setStorage(result === 'keyring' ? 'keyring' : 'database'); + toast.success( + result === 'keyring' + ? 'Master key moved to the OS keyring.' + : 'Master key moved back to the database.' + ); + } catch (error) { + console.error('Failed to change master key storage:', error); + toast.error(toErrorMessage(error, 'Failed to move the master key.')); + } + }; return (

Key Management

- Review how {session?.Username || 'your account'}'s encryption keys are protected. + Choose where your encrypted master key is stored.

-
- {keys.map((k) => ( -
-
-
- {k.icon} -
-

{k.title}

-
-

{k.description}

-

- {k.ok ? ( - - ) : ( - - )} - {k.status} +

+
+
+ +
+
+

+ Master Key Storage +

+

+ {storage === null ? 'Checking...' : storage === 'keyring' ? 'OS keyring' : 'Database'}

- ))} -
+
-
-

Recovery

-

- Your recovery key is essential for resetting your password. It is only ever shown once, so - if you lost it, resetting your password is the only way to obtain a new one. -

-
- +
+ handleToggle(e.target.checked)} + />
+ + setPendingStorage(null)} + />
); } diff --git a/frontend/wailsjs/go/auth/Service.d.ts b/frontend/wailsjs/go/auth/Service.d.ts index 78b4584..87f859a 100755 --- a/frontend/wailsjs/go/auth/Service.d.ts +++ b/frontend/wailsjs/go/auth/Service.d.ts @@ -7,6 +7,8 @@ export function CurrentClient():Promise; export function DatabaseConfig():Promise; +export function GetMasterKeyStorage():Promise; + export function GetSession():Promise; export function Login(arg1:auth.LoginInput):Promise; @@ -18,3 +20,5 @@ export function Register(arg1:auth.RegisterInput):Promise; export function RequireSession():Promise; export function ResetPassword(arg1:auth.ResetPasswordInput):Promise; + +export function SetMasterKeyStorage(arg1:string):Promise; diff --git a/frontend/wailsjs/go/auth/Service.js b/frontend/wailsjs/go/auth/Service.js index 7a566f1..d9c5271 100755 --- a/frontend/wailsjs/go/auth/Service.js +++ b/frontend/wailsjs/go/auth/Service.js @@ -10,6 +10,10 @@ export function DatabaseConfig() { return window['go']['auth']['Service']['DatabaseConfig'](); } +export function GetMasterKeyStorage() { + return window['go']['auth']['Service']['GetMasterKeyStorage'](); +} + export function GetSession() { return window['go']['auth']['Service']['GetSession'](); } @@ -33,3 +37,7 @@ export function RequireSession() { export function ResetPassword(arg1) { return window['go']['auth']['Service']['ResetPassword'](arg1); } + +export function SetMasterKeyStorage(arg1) { + return window['go']['auth']['Service']['SetMasterKeyStorage'](arg1); +} diff --git a/internal/features/auth/model.go b/internal/features/auth/model.go index b87ddd5..fd127c7 100644 --- a/internal/features/auth/model.go +++ b/internal/features/auth/model.go @@ -1,5 +1,9 @@ package auth +import ( + "ayo/internal/features/masterkey" +) + // User is the persisted representation of an account, mirroring one row of the // `users` table. It stores only hashes and encrypted material - never plaintext // credentials or keys. The plaintext recovery key is returned to the caller via @@ -32,3 +36,18 @@ type User struct { RecoveryNonce []byte RecoveryMasterKey []byte } + +// MasterKeyMaterial returns the user's encrypted master-key material as read +// from the users table. When the account stores its material in the OS keyring +// instead, these columns carry junk and callers must load the material from the +// keyring via the masterkey repository. +func (u *User) MasterKeyMaterial() *masterkey.Material { + return &masterkey.Material{ + PasswordSalt: u.PasswordSalt, + PasswordNonce: u.PasswordNonce, + PasswordMasterKey: u.PasswordMasterKey, + RecoverySalt: u.RecoverySalt, + RecoveryNonce: u.RecoveryNonce, + RecoveryMasterKey: u.RecoveryMasterKey, + } +} diff --git a/internal/features/auth/repository.go b/internal/features/auth/repository.go index 9874fb4..799a05b 100644 --- a/internal/features/auth/repository.go +++ b/internal/features/auth/repository.go @@ -9,6 +9,7 @@ import ( "sync" dbclient "ayo/internal/clients/db" + "ayo/internal/features/masterkey" "ayo/internal/shared/errors" ) @@ -29,16 +30,13 @@ type Repository interface { recoveryMasterKey []byte, ) (*User, error) GetUserByUsername(ctx context.Context, username string) (*User, error) - UpdateUserPassword( + UpdateUserHashes( ctx context.Context, id int64, passwordHash string, recoveryKey string, - passwordMasterKey []byte, - passwordNonce []byte, - recoveryMasterKey []byte, - recoveryNonce []byte, ) error + UpdateMasterKeyMaterial(ctx context.Context, id int64, material *masterkey.Material) error } type repository struct { @@ -197,21 +195,41 @@ func (r *repository) GetUserByUsername(ctx context.Context, username string) (*U return &user, nil } -// UpdateUserPassword replaces the password/recovery-key hashes and re-wraps the -// master key with the newly derived KEKs. Used by the reset-password flow. -func (r *repository) UpdateUserPassword( +// UpdateUserHashes replaces the password and recovery-key bcrypt hashes for the +// given user. Used by the reset-password flow; the encrypted master-key material +// is updated separately (see UpdateMasterKeyMaterial) so that keyring-stored +// accounts keep junk in the database. +func (r *repository) UpdateUserHashes( ctx context.Context, id int64, passwordHash string, recoveryKey string, - passwordMasterKey []byte, - passwordNonce []byte, - recoveryMasterKey []byte, - recoveryNonce []byte, ) error { - query := `UPDATE users SET password_hash = ?, recovery_key = ?, ` + - `password_master_key = ?, password_nonce = ?, recovery_master_key = ?, ` + - `recovery_nonce = ? WHERE id = ?` + query := `UPDATE users SET password_hash = ?, recovery_key = ? WHERE id = ?` + + client, err := r.resolve() + if err != nil { + return err + } + _, err = client.ExecContext( + ctx, + client.Rebind(query), + passwordHash, recoveryKey, id, + ) + if err != nil { + return fmt.Errorf("failed to update user hashes: %w", err) + } + return nil +} + +// UpdateMasterKeyMaterial replaces the six encrypted master-key columns for the +// given user. It is used to migrate the material between the users table and +// the OS keyring: when the material moves to the keyring, this writes +// indistinguishable random junk; when it moves back, it writes the real values. +func (r *repository) UpdateMasterKeyMaterial(ctx context.Context, id int64, material *masterkey.Material) error { + query := `UPDATE users SET password_salt = ?, password_nonce = ?, ` + + `password_master_key = ?, recovery_salt = ?, recovery_nonce = ?, ` + + `recovery_master_key = ? WHERE id = ?` client, err := r.resolve() if err != nil { @@ -220,11 +238,12 @@ func (r *repository) UpdateUserPassword( _, err = client.ExecContext( ctx, client.Rebind(query), - passwordHash, recoveryKey, passwordMasterKey, passwordNonce, - recoveryMasterKey, recoveryNonce, id, + material.PasswordSalt, material.PasswordNonce, material.PasswordMasterKey, + material.RecoverySalt, material.RecoveryNonce, material.RecoveryMasterKey, + id, ) if err != nil { - return fmt.Errorf("failed to update user password: %w", err) + return fmt.Errorf("failed to update master key material: %w", err) } return nil } diff --git a/internal/features/auth/service.go b/internal/features/auth/service.go index e5e7c29..23dbaf0 100644 --- a/internal/features/auth/service.go +++ b/internal/features/auth/service.go @@ -8,6 +8,7 @@ import ( dbclient "ayo/internal/clients/db" "ayo/internal/features/dbconfig" + "ayo/internal/features/masterkey" "ayo/internal/shared/crypto" "ayo/internal/shared/errors" "ayo/internal/shared/paths" @@ -51,6 +52,7 @@ type Session struct { type Service struct { conn *dbclient.Connection dbCreds dbconfig.Repository + mkey masterkey.Repository repo Repository session *Session dbConfig dbclient.Config @@ -76,9 +78,9 @@ func validatePasswordStrength(fl validator.FieldLevel) bool { } // NewService wires a shared connection holder, the database-credentials -// keyring repository and a validator with the custom password strength rule -// into a ready-to-use auth Service. -func NewService(conn *dbclient.Connection, dbCreds dbconfig.Repository) *Service { +// keyring repository, the master-key keyring repository and a validator with +// the custom password strength rule into a ready-to-use auth Service. +func NewService(conn *dbclient.Connection, dbCreds dbconfig.Repository, mkey masterkey.Repository) *Service { validate := validator.New() // Register custom password strength validator @@ -87,6 +89,7 @@ func NewService(conn *dbclient.Connection, dbCreds dbconfig.Repository) *Service return &Service{ conn: conn, dbCreds: dbCreds, + mkey: mkey, repo: NewRepository(conn), validate: validate, } @@ -276,14 +279,19 @@ func (s *Service) Login(input LoginInput) (bool, error) { return false, errors.ErrInvalidPassword } - // salt for the password - salt := user.PasswordSalt + // Load the encrypted master-key material from whichever source it lives in + // (the OS keyring when an entry exists, otherwise the users table). + material, err := s.loadMasterKeyMaterial(user) + if err != nil { + s.conn.Close() + return false, errors.AsInternalServerError("login: load master key material", err) + } - // derriving the kek - kek := crypto.DeriveKEK(input.Password, salt) + // deriving the KEK from the password and the stored salt + kek := crypto.DeriveKEK(input.Password, material.PasswordSalt) // decrypting the master key - masterKey, err := crypto.DecryptMasterKey(kek, user.PasswordMasterKey, user.PasswordNonce) + masterKey, err := crypto.DecryptMasterKey(kek, material.PasswordMasterKey, material.PasswordNonce) if err != nil { s.conn.Close() return false, errors.AsInternalServerError("login: decrypt master key", err) @@ -376,16 +384,24 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro return nil, errors.AsInternalServerError("reset password: hash recovery key", err) } + // Load the encrypted master-key material from whichever source it lives in + // (the OS keyring when an entry exists, otherwise the users table). + material, err := s.loadMasterKeyMaterial(user) + if err != nil { + s.conn.Close() + return nil, errors.AsInternalServerError("reset password: load master key material", err) + } + // extract the original master key using the provided recovery key - recoveryKek := crypto.DeriveKEK(input.RecoveryKey, user.RecoverySalt) - masterKey, err := crypto.DecryptMasterKey(recoveryKek, user.RecoveryMasterKey, user.RecoveryNonce) + recoveryKek := crypto.DeriveKEK(input.RecoveryKey, material.RecoverySalt) + masterKey, err := crypto.DecryptMasterKey(recoveryKek, material.RecoveryMasterKey, material.RecoveryNonce) if err != nil { s.conn.Close() return nil, errors.AsInternalServerError("reset password: decrypt master key", err) } // generate the new encrypted master key using password - passwordKek := crypto.DeriveKEK(input.NewPassword, user.PasswordSalt) + passwordKek := crypto.DeriveKEK(input.NewPassword, material.PasswordSalt) passwordEncryptedMasterKey, passwordNonce, err := crypto.EncryptMasterKey(passwordKek, masterKey) if err != nil { s.conn.Close() @@ -393,29 +409,37 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro } // generate the new encrypted master key using recovery key - recoveryKek = crypto.DeriveKEK(newRecoveryKey, user.RecoverySalt) + recoveryKek = crypto.DeriveKEK(newRecoveryKey, material.RecoverySalt) recoveryEncryptedMasterKey, recoveryNonce, err := crypto.EncryptMasterKey(recoveryKek, masterKey) if err != nil { s.conn.Close() return nil, errors.AsInternalServerError("reset password: encrypt master key with recovery key", err) } - // update the password and recovery key - err = s.repo.UpdateUserPassword( + // update the password and recovery key hashes + err = s.repo.UpdateUserHashes( context.Background(), user.ID, string(hashedPassword), string(hashedRecoveryKey), - passwordEncryptedMasterKey, - passwordNonce, - recoveryEncryptedMasterKey, - recoveryNonce, ) if err != nil { s.conn.Close() return nil, errors.AsInternalServerError("reset password: update user", err) } + // Re-wrap the master key in whichever source it lives in, so an account that + // stores its material in the keyring keeps the real values there while its + // users table columns stay junk, and vice versa. + material.PasswordNonce = passwordNonce + material.PasswordMasterKey = passwordEncryptedMasterKey + material.RecoveryNonce = recoveryNonce + material.RecoveryMasterKey = recoveryEncryptedMasterKey + if err := s.persistMasterKeyMaterial(context.Background(), user, material); err != nil { + s.conn.Close() + return nil, errors.AsInternalServerError("reset password: update master key material", err) + } + // Re-encrypt the database credentials with the new password and recovery // key so the account keeps its database. encryptedCreds, err := dbconfig.EncryptDBCredentials(input.NewPassword, newRecoveryKey, creds) @@ -483,6 +507,125 @@ func (s *Service) DatabaseConfig() (dbclient.Config, error) { return s.dbConfig, nil } +// GetMasterKeyStorage reports where the signed-in user's encrypted master-key +// material is kept: "keyring" when an entry exists in the OS keyring, otherwise +// "database" (the users table). It requires a signed-in session. +func (s *Service) GetMasterKeyStorage() (string, error) { + if _, err := s.RequireSession(); err != nil { + return "", err + } + storage, err := s.masterKeyStorage(s.session.Username) + if err != nil { + return "", errors.AsInternalServerError("get master key storage", err) + } + return string(storage), nil +} + +// SetMasterKeyStorage migrates the signed-in user's encrypted master-key +// material to the requested source ("keyring" or "database"). Both the +// password- and recovery-key-derived salt, nonce and ciphertext are copied to +// the target, and the source is cleared: the users table columns are filled +// with random junk when moving to the keyring, and the keyring entry is deleted +// when moving to the database. It returns the resulting storage state. +func (s *Service) SetMasterKeyStorage(storage string) (string, error) { + if _, err := s.RequireSession(); err != nil { + return "", err + } + target := masterkey.Storage(storage) + if target != masterkey.StorageDatabase && target != masterkey.StorageKeyring { + return "", errors.ErrInvalidInput + } + + current, err := s.masterKeyStorage(s.session.Username) + if err != nil { + return "", errors.AsInternalServerError("set master key storage: read state", err) + } + if target == current { + return string(current), nil + } + + user, err := s.repo.GetUserByUsername(context.Background(), s.session.Username) + if err != nil { + return "", errors.AsInternalServerError("set master key storage: get user", err) + } + + // The material currently lives in the source we are migrating away from. + material, err := s.loadMasterKeyMaterial(user) + if err != nil { + return "", errors.AsInternalServerError("set master key storage: load material", err) + } + + if target == masterkey.StorageKeyring { + // Move the real material into the keyring and fill the database columns + // with indistinguishable random junk. + if err := s.mkey.Save(s.session.Username, material); err != nil { + return "", errors.AsInternalServerError("set master key storage: save to keyring", err) + } + junk, err := masterkey.GenerateJunk() + if err != nil { + return "", errors.AsInternalServerError("set master key storage: generate junk", err) + } + if err := s.repo.UpdateMasterKeyMaterial(context.Background(), user.ID, junk); err != nil { + return "", errors.AsInternalServerError("set master key storage: junk database", err) + } + } else { + // Restore the real material into the database and drop the keyring + // entry so the state stays detectable from the keyring alone. + if err := s.repo.UpdateMasterKeyMaterial(context.Background(), user.ID, material); err != nil { + return "", errors.AsInternalServerError("set master key storage: restore database", err) + } + if err := s.mkey.Delete(s.session.Username); err != nil { + return "", errors.AsInternalServerError("set master key storage: delete keyring", err) + } + } + + return string(target), nil +} + +// masterKeyStorage reports whether a keyring entry exists for the user. It is +// the single source of truth for the storage state: present => keyring storage, +// absent => database storage. +func (s *Service) masterKeyStorage(username string) (masterkey.Storage, error) { + exists, err := s.mkey.Exists(username) + if err != nil { + return "", err + } + if exists { + return masterkey.StorageKeyring, nil + } + return masterkey.StorageDatabase, nil +} + +// loadMasterKeyMaterial returns a user's encrypted master-key material from +// whichever source it currently lives in: the OS keyring when an entry exists, +// otherwise the users table row. +func (s *Service) loadMasterKeyMaterial(user *User) (*masterkey.Material, error) { + exists, err := s.mkey.Exists(user.Username) + if err != nil { + return nil, err + } + if exists { + return s.mkey.Load(user.Username) + } + return user.MasterKeyMaterial(), nil +} + +// persistMasterKeyMaterial writes the encrypted master-key material to the +// source it is currently stored in (the OS keyring when an entry exists, +// otherwise the users table). The other source is left untouched, so the +// keyring entry and the database junk stay consistent for keyring-stored +// accounts. +func (s *Service) persistMasterKeyMaterial(ctx context.Context, user *User, material *masterkey.Material) error { + exists, err := s.mkey.Exists(user.Username) + if err != nil { + return err + } + if exists { + return s.mkey.Save(user.Username, material) + } + return s.repo.UpdateMasterKeyMaterial(ctx, user.ID, material) +} + // validateDBConfig enforces type-specific field requirements on the chosen // database configuration. func validateDBConfig(config dbclient.Config) error { diff --git a/internal/features/masterkey/model.go b/internal/features/masterkey/model.go new file mode 100644 index 0000000..96d8b38 --- /dev/null +++ b/internal/features/masterkey/model.go @@ -0,0 +1,31 @@ +package masterkey + +// Storage identifies where a user's encrypted master-key material is kept. It +// is derived from the OS keyring: a keyring entry exists => keyring storage, no +// entry => database storage. The frontend toggles between the two, and the auth +// service migrates the material (and junk-fills / deletes the other source) +// accordingly. +type Storage string + +const ( + // StorageDatabase keeps the encrypted master-key material in the users + // table. It is the default and requires no keyring entry. + StorageDatabase Storage = "database" + // StorageKeyring keeps the encrypted master-key material in the OS keyring + // under "ayo"/"mkey_{username}". When active, the users table columns hold + // random junk so a stolen database exposes no real key material. + StorageKeyring Storage = "keyring" +) + +// Material is the complete set of values needed to unwrap the master key: the +// salt, nonce and GCM ciphertext for both the password-derived and +// recovery-key-derived KEKs. It mirrors the six users table columns and is what +// gets moved between the database and the OS keyring. +type Material struct { + PasswordSalt []byte + PasswordNonce []byte + PasswordMasterKey []byte + RecoverySalt []byte + RecoveryNonce []byte + RecoveryMasterKey []byte +} diff --git a/internal/features/masterkey/repository.go b/internal/features/masterkey/repository.go new file mode 100644 index 0000000..5244c30 --- /dev/null +++ b/internal/features/masterkey/repository.go @@ -0,0 +1,164 @@ +package masterkey + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "strings" + + "ayo/internal/platform/keyring" +) + +// ErrMasterKeyNotFound is returned by Load when no master-key keyring entry +// exists for the user. It signals database storage (see Repository.Exists). +var ErrMasterKeyNotFound = errors.New("master key not found in keyring") + +// Repository abstracts persistence of the encrypted master-key material in the +// OS keyring. It mirrors the settings and dbconfig keyring repositories: the +// material is JSON-encoded, base64-encoded and stored under the "ayo" service, +// keyed by user ("mkey_{username}") to keep it separate from the "ayo" and +// "dbcreds_" entries. +type Repository interface { + // Load returns the stored material, or ErrMasterKeyNotFound when nothing + // has been saved yet. + Load(username string) (*Material, error) + // Save replaces the stored material for the given user. + Save(username string, material *Material) error + // Delete removes the stored material for the given user. Removing an entry + // that does not exist is not an error. + Delete(username string) error + // Exists reports whether a keyring entry is stored for the user. This is + // the source of truth for the storage state: present => keyring storage, + // absent => database storage. + Exists(username string) (bool, error) +} + +type repository struct{} + +// NewRepository returns a ready-to-use keyring repository. +func NewRepository() Repository { + return &repository{} +} + +// keyringUser maps an account username to the keyring entry holding its +// encrypted master-key material. +func keyringUser(username string) string { + return "mkey_" + username +} + +func (r *repository) Load(username string) (*Material, error) { + encoded, err := keyring.Get("ayo", keyringUser(username)) + if err != nil { + if isKeyringNotFound(err) { + return nil, ErrMasterKeyNotFound + } + return nil, fmt.Errorf("load master key from keyring: %w", err) + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decode master key blob: %w", err) + } + + var material Material + if err := json.Unmarshal(decoded, &material); err != nil { + return nil, fmt.Errorf("unmarshal master key blob: %w", err) + } + return &material, nil +} + +func (r *repository) Save(username string, material *Material) error { + raw, err := json.Marshal(material) + if err != nil { + return fmt.Errorf("marshal master key blob: %w", err) + } + encoded := base64.StdEncoding.EncodeToString(raw) + if err := keyring.Set("ayo", keyringUser(username), encoded); err != nil { + return fmt.Errorf("save master key to keyring: %w", err) + } + return nil +} + +func (r *repository) Delete(username string) error { + if err := keyring.Delete("ayo", keyringUser(username)); err != nil { + return fmt.Errorf("delete master key from keyring: %w", err) + } + return nil +} + +func (r *repository) Exists(username string) (bool, error) { + _, err := r.Load(username) + if err != nil { + if errors.Is(err, ErrMasterKeyNotFound) { + return false, nil + } + return false, err + } + return true, nil +} + +// isKeyringNotFound reports whether a keyring lookup failed because nothing is +// stored for the given user. The not-found marker differs across platforms and +// OS versions, so this matches both the library's sentinel error and the +// platform error text (e.g. macOS `security` prints "could not be found"). +func isKeyringNotFound(err error) bool { + if errors.Is(err, keyring.ErrNotFound) { + return true + } + msg := err.Error() + return strings.Contains(msg, "could not be found") || + strings.Contains(msg, "item not found") || + strings.Contains(msg, "no entry") || + strings.Contains(msg, "not exist") +} + +// GenerateJunk returns a Material filled with random bytes sized like real +// encrypted master-key material. It is written to the users table columns while +// the real material lives in the OS keyring, so a stolen database offers no +// usable key material and the junk is indistinguishable from the real ciphertext +// (same lengths: 16-byte salts, 12-byte nonces, 48-byte wrapped keys). +func GenerateJunk() (*Material, error) { + bytes := func(n int) ([]byte, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return nil, err + } + return b, nil + } + + passwordSalt, err := bytes(16) + if err != nil { + return nil, fmt.Errorf("generate junk password salt: %w", err) + } + passwordNonce, err := bytes(12) + if err != nil { + return nil, fmt.Errorf("generate junk password nonce: %w", err) + } + passwordMasterKey, err := bytes(48) + if err != nil { + return nil, fmt.Errorf("generate junk password master key: %w", err) + } + recoverySalt, err := bytes(16) + if err != nil { + return nil, fmt.Errorf("generate junk recovery salt: %w", err) + } + recoveryNonce, err := bytes(12) + if err != nil { + return nil, fmt.Errorf("generate junk recovery nonce: %w", err) + } + recoveryMasterKey, err := bytes(48) + if err != nil { + return nil, fmt.Errorf("generate junk recovery master key: %w", err) + } + + return &Material{ + PasswordSalt: passwordSalt, + PasswordNonce: passwordNonce, + PasswordMasterKey: passwordMasterKey, + RecoverySalt: recoverySalt, + RecoveryNonce: recoveryNonce, + RecoveryMasterKey: recoveryMasterKey, + }, nil +} diff --git a/internal/platform/keyring/keyring.go b/internal/platform/keyring/keyring.go index adf21cf..a211a6a 100644 --- a/internal/platform/keyring/keyring.go +++ b/internal/platform/keyring/keyring.go @@ -8,6 +8,8 @@ package keyring import ( + "errors" + go_keyring "github.com/zalando/go-keyring" ) @@ -29,3 +31,14 @@ func Get(service, user string) (string, error) { func Set(service, user, value string) error { return go_keyring.Set(service, user, value) } + +// Delete removes the entry stored for the given service/user pair. Deleting an +// entry that does not exist is treated as success (idempotent), matching how +// some backends report a missing item as ErrNotFound. +func Delete(service, user string) error { + err := go_keyring.Delete(service, user) + if err != nil && errors.Is(err, go_keyring.ErrNotFound) { + return nil + } + return err +} diff --git a/main.go b/main.go index 04b3e1b..f649375 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "ayo/internal/features/auth" "ayo/internal/features/dbconfig" "ayo/internal/features/home" + "ayo/internal/features/masterkey" "ayo/internal/features/recovery" "ayo/internal/features/settings" "ayo/internal/features/upload" @@ -66,9 +67,13 @@ func main() { // and is injected into the settings service (which needs the session to // gate access and the master key to encrypt/decrypt stored settings). // Database credentials are persisted in the OS keyring through the dbconfig - // feature. + // feature. The encrypted master-key material can likewise live in the OS + // keyring (account-scoped "mkey_{username}") or in the users table; the + // masterkey repository is the keyring side of that choice, and the auth + // service migrates between the two via Get/SetMasterKeyStorage. dbconfigRepository := dbconfig.NewRepository() - authService := auth.NewService(conn, dbconfigRepository) + masterkeyRepository := masterkey.NewRepository() + authService := auth.NewService(conn, dbconfigRepository, masterkeyRepository) // Recovery service: native save dialogs for downloading the recovery key. recoveryService := recovery.NewService()