diff --git a/AGENTS.md b/AGENTS.md index 14e6a15..b5d5f8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,26 +4,41 @@ Wails v2 desktop app (`ayo`): Go 1.24 backend + React 18 / TypeScript / Vite 3 / ## Architecture -The Go backend is tiered under `internal/`: +The Go backend is tiered under `internal/`, one tier per directory. A tier may +depend only on the tiers below it: -- `internal/features/` — business logic, one package per feature: - - `auth/` — Register/Login/ResetPassword/Logout + in-memory session (`MasterKey`). Argon2id + AES-GCM. Layered as `dto.go` / `model.go` / `repository.go` / `service.go`. The service owns the active per-user database connection (via `internal/clients/db`'s `Connection`) and opens/closes it on login/logout; the full DB config (incl. PostgreSQL password) stays on the service, never in `Session` (which is serialized to the frontend). - - `dbconfig/` — dual-encrypted (password-KEK + recovery-KEK) per-user database credentials stored in the OS keyring under `ayo`/`dbcreds_{username}`. `model.go` / `crypto.go` / `repository.go`. - - `settings/` — per-user settings stored in the OS keyring (`zalando/go-keyring`), encrypted with the session master key. Keyring persistence is in `repository.go`; cloud-key types in `cloud.go`; validated Wails-bound input in `dto.go`. `GetDatabaseInfo()` returns sanitized (no password) DB info for the read-only Database tab. - - `recovery/` — save-file dialog for downloading the recovery key (shown after register/reset). - - `queue/` — dead code: not wired into `main.go`, and its SQL is MySQL-flavored (`AUTO_INCREMENT`, `JSON` type, `ON UPDATE CURRENT_TIMESTAMP`) and will not run on SQLite. Don't build on it. -- `internal/clients/` — driver-backed client abstractions: +``` + features business logic; the React app talks only to these + ↑ + platform platform-level infrastructure (queue, keyring); may import clients + ↑ + clients third-party client adapters (AWS S3, databases, ...) + ↑ + shared cross-cutting utilities; anyone may import +``` + +Concretely: `shared` is importable by every tier; `clients` may be imported by +anything above it; `platform` may call `clients`; `features` may call `platform`, +`clients`, and `shared` — but only through the feature's own `Repository` (see +Conventions below). + +- `internal/clients/` — third-party client adapters for external services: - `db/` — dialect-aware database client (`sqlite` via `modernc.org/sqlite`, `postgresql` via `github.com/lib/pq`). `Config` + `NewClient`/`Validate`, `Client` embeds `*sql.DB` and carries its `Dialect`, `Rebind()` rewrites `?`→`$N` for PostgreSQL, and `Connection` is the shared per-session connection holder that repositories resolve per operation (tables created lazily via `initializeTable`). - - `storage/` — storage provider clients and dispatch (see below). -- `internal/platform/` — infrastructure (never imported by features' business logic directly beyond what the feature's own repository wraps): + - `storage/` — storage provider clients (local filesystem, S3) and dispatch. +- `internal/platform/` — platform-level infrastructure; may call `clients`: - `keyring/` — thin wrapper over `zalando/go-keyring`. - - `dialog/` — native Wails save-file dialog wrapper. - - `queue/` — job queue (one `Job` per queued file, with status + progress) backed by the signed-in user's database. Wired in `main.go` and consumed by the `upload` feature's business logic through a narrow interface; prefer keeping that dependency behind the feature's own repository if possible. -- `internal/shared/` — cross-cutting code: + - `queue/` — job queue (one `Job` per queued file, with status + progress) backed by the signed-in user's database via `internal/clients/db`. Wired in `main.go` and consumed by the `upload` feature. +- `internal/features/` — business logic, one package per feature; the React app interacts with the backend only through these: + - `auth/` — Register/Login/ResetPassword/Logout + in-memory session (`MasterKey`). Argon2id + AES-GCM. Layered as `dto.go` / `model.go` / `repository.go` / `service.go`. The repository owns the active per-user database connection (via `internal/clients/db`'s `Connection`) and opens/closes it on login/logout; the DB config (incl. PostgreSQL password) never surfaces in `Session` (which is serialized to the frontend). `auth` also persists database credentials (`dbcreds_{username}`, dual-encrypted via `crypto.DualEncrypt`) and encrypted master-key material (`mkey_{username}`) in the OS keyring through its `Repository`. `SaveRecoveryKey` (native save-file dialog via `shared/dialog`) downloads the recovery key shown after register/reset. + - `settings/` — per-user settings stored in the OS keyring (`zalando/go-keyring`), encrypted with the session master key. Keyring persistence is in `repository.go`; cloud-key types in `cloud.go`; validated Wails-bound input in `dto.go`. `GetDatabaseInfo()` returns sanitized (no password) DB info for the read-only Database tab. + - `upload/` — native file picker plus one job per uploaded file in the `platform/queue`; the processor (`processor.go`) encrypts each file, splits it into erasure-coded shards (`erasure.go`), and writes them through the storage client, all via the repository. + - `home/` — dashboard: uploads overview and storage totals. Its repository owns the read-side queries and delegates the reads shared with the upload flow (`GetUpload`, `GetChunks`) to the upload feature's repository. +- `internal/shared/` — cross-cutting utilities; anyone may import: - `errors/` — sentinel errors with user-facing messages; return these (not wrapped fmt errors) so the frontend can display them. Also the `InternalServerError` type, `ErrDatabaseUnavailable` and `ErrNoStorageProvider`. - - `crypto/` — Argon2 KEK derivation + AES-256-GCM encrypt/decrypt primitives. + - `crypto/` — Argon2 KEK derivation + AES-256-GCM encrypt/decrypt primitives. Includes `DualEncrypt`/`DualDecrypt` (password-KEK + recovery-KEK dual wrap) and `Wipe` for scrubbing plaintext key material; all crypto lives here, never in features. - `paths/` — `GetAppDataDir()` for the OS app data directory where per-user SQLite files live. -- `main.go` — entrypoint. Wires `auth`, `settings`, `recovery`, `upload` services and binds them to the frontend via `wails.Run`. No global database: a shared `dbclient.Connection` is created and passed to auth/queue/upload. + - `dialog/` — native Wails save-file/directory dialog wrapper. +- `main.go` — entrypoint. Wires `auth`, `settings`, `upload`, `home` services and binds them to the frontend via `wails.Run`. No global database: a shared `dbclient.Connection` is created and passed to auth/queue/upload. - `assets.go` — `//go:embed all:frontend/dist`; the compiled frontend is embedded into the Go binary. - `frontend/` — React SPA. Calls Go through generated bindings (below). `@/` aliases `frontend/src`. - `data/` — gitignored runtime data (`encrypted/`, `downloads/`). Per-user databases live in the OS app data directory, not here. @@ -46,6 +61,8 @@ The Go backend is tiered under `internal/`: ## Conventions +- **Service is the feature's public surface.** Only `Service` methods are bound to the frontend via Wails; the React app interacts with the backend only through features. Within a feature, `dto.go` validates Wails-bound input, `model.go` holds the types, `repository.go` owns persistence. +- **Service calls the repository; the repository interacts with clients/platform.** A feature's service never touches `internal/clients` or `internal/platform` directly — if a service needs a client or platform package, it goes through the feature's single `Repository`. The repository is optional if a feature has no persistence; otherwise one repository per file, one service per file. - Auth validation uses `go-playground/validator` with a custom `password_strength` rule: passwords must contain upper + lower + digit + symbol. - Repositories write queries with `?` placeholders and run them through the client's `Rebind()` (a no-op on SQLite, `?`→`$N` on PostgreSQL). `initializeTable` and insert-ID retrieval (`LastInsertId` vs `RETURNING id`) branch on the dialect. - Frontend: TypeScript, ESLint, Prettier; commit formatted/linted code (`format` + `lint` pass in CI). diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 7608ca9..bf82759 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -6,9 +6,9 @@ import { Logout as LogoutService, Register as RegisterService, ResetPassword as ResetPasswordService, + SaveRecoveryKey as SaveRecoveryKeyService, } from '../../wailsjs/go/auth/Service'; import { auth } from '../../wailsjs/go/models'; -import { SaveRecoveryKey as SaveRecoveryKeyService } from '../../wailsjs/go/recovery/Service'; export type RegisterDbConfig = { type: 'sqlite' | 'postgresql'; diff --git a/frontend/src/pages/auth/Register/index.tsx b/frontend/src/pages/auth/Register/index.tsx index 6961b9f..9cd4274 100644 --- a/frontend/src/pages/auth/Register/index.tsx +++ b/frontend/src/pages/auth/Register/index.tsx @@ -60,6 +60,10 @@ export default function Register() { setIsSaving(true); try { await saveRecoveryKey(accountData.username, recoveryKey); + // The key is only ever shown once; drop the reference as soon as the + // save completes so the JS heap copy is released promptly. + setRecoveryKey(null); + setAccountData(null); toast.success(t('auth.recoveryKeySaved')); navigate('/auth/login'); } catch (err) { diff --git a/frontend/src/pages/auth/Reset/index.tsx b/frontend/src/pages/auth/Reset/index.tsx index 4f7b09a..98ff8bd 100644 --- a/frontend/src/pages/auth/Reset/index.tsx +++ b/frontend/src/pages/auth/Reset/index.tsx @@ -63,6 +63,9 @@ export default function Reset() { try { const username = getValues('username'); await saveRecoveryKey(username, newRecoveryKey); + // The key is only ever shown once; drop the reference as soon as the + // save completes so the JS heap copy is released promptly. + setNewRecoveryKey(null); toast.success(t('auth.recoveryKeySaved')); navigate('/auth/login'); } catch (err) { diff --git a/frontend/wailsjs/go/auth/Service.d.ts b/frontend/wailsjs/go/auth/Service.d.ts index 87f859a..7ca2bb0 100755 --- a/frontend/wailsjs/go/auth/Service.d.ts +++ b/frontend/wailsjs/go/auth/Service.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated. DO NOT EDIT import {db} from '../models'; import {auth} from '../models'; +import {context} from '../models'; export function CurrentClient():Promise; @@ -21,4 +22,8 @@ export function RequireSession():Promise; export function ResetPassword(arg1:auth.ResetPasswordInput):Promise; +export function SaveRecoveryKey(arg1:string,arg2:string):Promise; + export function SetMasterKeyStorage(arg1:string):Promise; + +export function Startup(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/auth/Service.js b/frontend/wailsjs/go/auth/Service.js index d9c5271..047b0fb 100755 --- a/frontend/wailsjs/go/auth/Service.js +++ b/frontend/wailsjs/go/auth/Service.js @@ -38,6 +38,14 @@ export function ResetPassword(arg1) { return window['go']['auth']['Service']['ResetPassword'](arg1); } +export function SaveRecoveryKey(arg1, arg2) { + return window['go']['auth']['Service']['SaveRecoveryKey'](arg1, arg2); +} + export function SetMasterKeyStorage(arg1) { return window['go']['auth']['Service']['SetMasterKeyStorage'](arg1); } + +export function Startup(arg1) { + return window['go']['auth']['Service']['Startup'](arg1); +} diff --git a/frontend/wailsjs/go/recovery/Service.d.ts b/frontend/wailsjs/go/recovery/Service.d.ts deleted file mode 100755 index 9a803fb..0000000 --- a/frontend/wailsjs/go/recovery/Service.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT -import {context} from '../models'; - -export function SaveRecoveryKey(arg1:string,arg2:string):Promise; - -export function Startup(arg1:context.Context):Promise; diff --git a/frontend/wailsjs/go/recovery/Service.js b/frontend/wailsjs/go/recovery/Service.js deleted file mode 100755 index bcbe3aa..0000000 --- a/frontend/wailsjs/go/recovery/Service.js +++ /dev/null @@ -1,11 +0,0 @@ -// @ts-check -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -export function SaveRecoveryKey(arg1, arg2) { - return window['go']['recovery']['Service']['SaveRecoveryKey'](arg1, arg2); -} - -export function Startup(arg1) { - return window['go']['recovery']['Service']['Startup'](arg1); -} diff --git a/internal/features/auth/model.go b/internal/features/auth/model.go index 3c910fc..cda5d31 100644 --- a/internal/features/auth/model.go +++ b/internal/features/auth/model.go @@ -1,7 +1,7 @@ package auth import ( - "ayo/internal/features/masterkey" + dbclient "ayo/internal/clients/db" ) // User is the persisted representation of an account, mirroring one row of the @@ -46,9 +46,9 @@ type User struct { // 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{ +// keyring via the master-key repository. +func (u *User) MasterKeyMaterial() *Material { + return &Material{ PasswordSalt: u.passwordSalt, PasswordNonce: u.passwordNonce, PasswordMasterKey: u.passwordMasterKey, @@ -57,3 +57,75 @@ func (u *User) MasterKeyMaterial() *masterkey.Material { RecoveryMasterKey: u.recoveryMasterKey, } } + +// DBCredentials is the plaintext database configuration for one account. It is +// serialized to JSON, dual-encrypted (password-KEK + recovery-KEK) and stored +// in the OS keyring; only the encrypted blob ever persists. The password is +// stored here too (it is needed to open the connection at login) but is never +// exposed to the frontend. +type DBCredentials struct { + Type dbclient.Dialect `json:"Type"` + Path string `json:"Path,omitempty"` + Host string `json:"Host,omitempty"` + Port int `json:"Port,omitempty"` + Database string `json:"Database,omitempty"` + Username string `json:"Username,omitempty"` + Password string `json:"Password,omitempty"` +} + +// ToConfig converts the stored credentials into a client config usable with +// dbclient.NewClient / dbclient.Validate. +func (d DBCredentials) ToConfig() dbclient.Config { + return dbclient.Config{ + Type: d.Type, + Path: d.Path, + Host: d.Host, + Port: d.Port, + Database: d.Database, + Username: d.Username, + Password: d.Password, + } +} + +// FromConfig builds stored credentials from a client config. +func FromConfig(c dbclient.Config) DBCredentials { + return DBCredentials{ + Type: c.Type, + Path: c.Path, + Host: c.Host, + Port: c.Port, + Database: c.Database, + Username: c.Username, + Password: c.Password, + } +} + +// 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/auth/repository.go b/internal/features/auth/repository.go index fe6fb7c..b220342 100644 --- a/internal/features/auth/repository.go +++ b/internal/features/auth/repository.go @@ -2,20 +2,26 @@ package auth import ( "context" + "crypto/rand" "database/sql" + "encoding/base64" + "encoding/json" stderrors "errors" "fmt" "strings" "sync" dbclient "ayo/internal/clients/db" - "ayo/internal/features/masterkey" + "ayo/internal/platform/keyring" + "ayo/internal/shared/crypto" "ayo/internal/shared/errors" ) -// Repository abstracts persistence for the auth module. Keeping it behind an -// interface makes the service testable with a fake implementation instead of a -// real SQLite database. +// Repository abstracts persistence for the auth module. It is the only thing +// that touches the database (via internal/clients/db) and the OS keyring (via +// internal/platform/keyring); the service calls the repository and never +// reaches into clients/platform directly. Keeping it behind an interface makes +// the service testable with a fake implementation instead of a real database. type Repository interface { CreateUser( ctx context.Context, @@ -36,7 +42,14 @@ type Repository interface { passwordHash string, recoveryKey string, ) error - UpdateMasterKeyMaterial(ctx context.Context, id int64, material *masterkey.Material) error + UpdateMasterKeyMaterial(ctx context.Context, id int64, material *Material) error + CredentialsExists(username string) (bool, error) + SaveCredentials(username string, password, recoveryKey []byte, creds DBCredentials) error + LoadCredentials(username string, secret []byte, fromPassword bool) (DBCredentials, error) + MasterKeyKeyringExists(username string) (bool, error) + SaveMasterKeyKeyring(username string, material *Material) error + LoadMasterKeyKeyring(username string) (*Material, error) + DeleteMasterKeyKeyring(username string) error } type repository struct { @@ -226,7 +239,7 @@ func (r *repository) UpdateUserHashes( // 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 { +func (r *repository) UpdateMasterKeyMaterial(ctx context.Context, id int64, material *Material) error { query := `UPDATE users SET password_salt = ?, password_nonce = ?, ` + `password_master_key = ?, recovery_salt = ?, recovery_nonce = ?, ` + `recovery_master_key = ? WHERE id = ?` @@ -247,3 +260,251 @@ func (r *repository) UpdateMasterKeyMaterial(ctx context.Context, id int64, mate } return nil } + +// ErrCredentialsNotFound is returned by loadDBCreds when no keyring entry exists +// for the user. It is an internal marker (mapped by the service to +// ErrUserNotFound) rather than a user-facing message. +var ErrCredentialsNotFound = stderrors.New("database credentials not found in keyring") + +// CredentialsExists reports whether a database-credentials keyring entry exists +// for the user. It is the machine-level account marker: every registered +// account saves an entry and never deletes it, so its presence means a username +// is already taken on this device. +func (r *repository) CredentialsExists(username string) (bool, error) { + return dbCredsExists(username) +} + +// SaveCredentials serializes creds, dual-encrypts them (password-KEK + +// recovery-KEK) and persists the blob in the OS keyring. password and +// recoveryKey must be mutable copies of the secrets (see crypto.Wipe). +func (r *repository) SaveCredentials(username string, password, recoveryKey []byte, creds DBCredentials) error { + encrypted, err := encryptDBCreds(password, recoveryKey, creds) + if err != nil { + return err + } + return saveDBCreds(username, encrypted) +} + +// LoadCredentials loads the encrypted database-credentials blob and unwraps it +// with the given secret. fromPassword selects the password-derived KEK (login) +// or the recovery-key-derived KEK (password reset). secret must be a mutable +// copy (see crypto.Wipe). A wrong secret fails GCM authentication and returns +// an error. +func (r *repository) LoadCredentials(username string, secret []byte, fromPassword bool) (DBCredentials, error) { + blob, err := loadDBCreds(username) + if err != nil { + return DBCredentials{}, err + } + return decryptDBCreds(secret, blob, fromPassword) +} + +// MasterKeyKeyringExists reports whether a master-key keyring entry exists for +// the user. It is the source of truth for the storage state: present => keyring +// storage, absent => database storage. +func (r *repository) MasterKeyKeyringExists(username string) (bool, error) { + return masterKeyKeyringExists(username) +} + +// SaveMasterKeyKeyring replaces the user's encrypted master-key material in the +// OS keyring. +func (r *repository) SaveMasterKeyKeyring(username string, material *Material) error { + return saveMasterKeyKeyring(username, material) +} + +// LoadMasterKeyKeyring returns the user's encrypted master-key material from +// the OS keyring, or ErrMasterKeyNotFound when no entry exists. +func (r *repository) LoadMasterKeyKeyring(username string) (*Material, error) { + return loadMasterKeyKeyring(username) +} + +// DeleteMasterKeyKeyring removes the user's master-key keyring entry. Removing +// an entry that does not exist is not an error. +func (r *repository) DeleteMasterKeyKeyring(username string) error { + return deleteMasterKeyKeyring(username) +} + +// dbCredsKeyringUser maps an account username to the keyring entry holding its +// database credentials, keeping it separate from the "ayo" entries used by +// settings and the "mkey_" entries used by the master-key keyring. +func dbCredsKeyringUser(username string) string { + return "dbcreds_" + username +} + +// loadDBCreds returns the encrypted database-credentials blob for the user, or +// ErrCredentialsNotFound when nothing has been saved yet. +func loadDBCreds(username string) ([]byte, error) { + encoded, err := keyring.Get("ayo", dbCredsKeyringUser(username)) + if err != nil { + if keyring.IsNotFound(err) { + return nil, ErrCredentialsNotFound + } + return nil, fmt.Errorf("load database credentials from keyring: %w", err) + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decode database credentials blob: %w", err) + } + return decoded, nil +} + +// saveDBCreds replaces the encrypted database-credentials blob for the user. +func saveDBCreds(username string, data []byte) error { + encoded := base64.StdEncoding.EncodeToString(data) + if err := keyring.Set("ayo", dbCredsKeyringUser(username), encoded); err != nil { + return fmt.Errorf("save database credentials to keyring: %w", err) + } + return nil +} + +// dbCredsExists reports whether a database-credentials entry is stored for the +// user. It is the machine-level account marker: every registered account saves +// an entry and never deletes it, so its presence means a username is already +// taken on this device. +func dbCredsExists(username string) (bool, error) { + return keyring.Exists("ayo", dbCredsKeyringUser(username)) +} + +// encryptDBCreds serializes creds and dual-encrypts them (password-KEK + +// recovery-KEK) via crypto.DualEncrypt. password and recoveryKey must be +// mutable copies of the secrets (see crypto.Wipe); the transient plaintext JSON +// is scrubbed before returning. +func encryptDBCreds(password, recoveryKey []byte, creds DBCredentials) ([]byte, error) { + plaintext, err := json.Marshal(creds) + if err != nil { + return nil, err + } + defer crypto.Wipe(plaintext) + return crypto.DualEncrypt(plaintext, password, recoveryKey) +} + +// decryptDBCreds unwraps a blob previously produced by encryptDBCreds. +// fromPassword selects the password-derived KEK (login) or the +// recovery-key-derived KEK (password reset). secret must be a mutable copy of +// the secret (see crypto.Wipe); the transient plaintext JSON is scrubbed before +// returning. +func decryptDBCreds(secret []byte, blob []byte, fromPassword bool) (DBCredentials, error) { + plaintext, err := crypto.DualDecrypt(blob, secret, fromPassword) + if err != nil { + return DBCredentials{}, err + } + defer crypto.Wipe(plaintext) + + var creds DBCredentials + if err := json.Unmarshal(plaintext, &creds); err != nil { + return DBCredentials{}, err + } + return creds, nil +} + +// ErrMasterKeyNotFound is returned by loadMasterKeyKeyring when no keyring entry +// exists for the user. It signals database storage (see masterKeyKeyringExists). +var ErrMasterKeyNotFound = stderrors.New("master key not found in keyring") + +// masterKeyKeyringUser maps an account username to the keyring entry holding its +// encrypted master-key material. +func masterKeyKeyringUser(username string) string { + return "mkey_" + username +} + +// loadMasterKeyKeyring returns the user's encrypted master-key material from the +// OS keyring, or ErrMasterKeyNotFound when no entry exists. +func loadMasterKeyKeyring(username string) (*Material, error) { + encoded, err := keyring.Get("ayo", masterKeyKeyringUser(username)) + if err != nil { + if keyring.IsNotFound(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 +} + +// saveMasterKeyKeyring replaces the user's encrypted master-key material in the +// OS keyring. +func saveMasterKeyKeyring(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", masterKeyKeyringUser(username), encoded); err != nil { + return fmt.Errorf("save master key to keyring: %w", err) + } + return nil +} + +// deleteMasterKeyKeyring removes the user's master-key keyring entry. Removing +// an entry that does not exist is not an error. +func deleteMasterKeyKeyring(username string) error { + if err := keyring.Delete("ayo", masterKeyKeyringUser(username)); err != nil { + return fmt.Errorf("delete master key from keyring: %w", err) + } + return nil +} + +// masterKeyKeyringExists reports whether a keyring entry is stored for the user. +// It is the source of truth for the storage state: present => keyring storage, +// absent => database storage. +func masterKeyKeyringExists(username string) (bool, error) { + return keyring.Exists("ayo", masterKeyKeyringUser(username)) +} + +// 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/features/auth/service.go b/internal/features/auth/service.go index 92ff3b0..1d82170 100644 --- a/internal/features/auth/service.go +++ b/internal/features/auth/service.go @@ -3,12 +3,12 @@ package auth import ( "context" stderrors "errors" + "os" "regexp" dbclient "ayo/internal/clients/db" - "ayo/internal/features/dbconfig" - "ayo/internal/features/masterkey" "ayo/internal/shared/crypto" + "ayo/internal/shared/dialog" "ayo/internal/shared/errors" "github.com/go-playground/validator/v10" @@ -57,15 +57,20 @@ func (s *Session) MasterKey() []byte { // replaced with the vague *errors.InternalServerError so that no implementation // detail ever leaks to the UI. type Service struct { + ctx context.Context conn *dbclient.Connection - dbCreds dbconfig.Repository - mkey masterkey.Repository repo Repository session *Session dbConfig dbclient.Config validate *validator.Validate } +// Startup stores the Wails application context, which native dialogs (e.g. +// SaveRecoveryKey) require. +func (s *Service) Startup(ctx context.Context) { + s.ctx = ctx +} + // validatePasswordStrength enforces that a password contains at least one // uppercase letter, one lowercase letter, one digit and one symbol. It is // registered as the "password_strength" validator rule. @@ -87,7 +92,7 @@ func validatePasswordStrength(fl validator.FieldLevel) bool { // NewService wires a shared connection holder, the database-credentials // 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 { +func NewService(conn *dbclient.Connection) *Service { validate := validator.New() // Register custom password strength validator @@ -95,8 +100,6 @@ func NewService(conn *dbclient.Connection, dbCreds dbconfig.Repository, mkey mas return &Service{ conn: conn, - dbCreds: dbCreds, - mkey: mkey, repo: NewRepository(conn), validate: validate, } @@ -129,7 +132,7 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { // that is never deleted. Without this check, registering the same username // against a different database would silently overwrite the existing // account's keyring entry. - exists, err := s.dbCreds.Exists(input.Username) + exists, err := s.repo.CredentialsExists(input.Username) if err != nil { return nil, errors.AsInternalServerError("register: check keychain for existing account", err) } @@ -171,8 +174,12 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { if err != nil { return nil, errors.AsInternalServerError("register: generate recovery key", err) } + defer crypto.Wipe(recoveryKey) - hashedPassword, err := crypto.HashPassword(input.Password) + passwordBytes := []byte(input.Password) + defer crypto.Wipe(passwordBytes) + + hashedPassword, err := crypto.HashPassword(passwordBytes) if err != nil { return nil, errors.AsInternalServerError("register: hash password", err) } @@ -199,9 +206,13 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { if err != nil { return nil, errors.AsInternalServerError("register: generate master key", err) } + // Registration does not create a session, so the master key is transient + // and must be scrubbed once the wrapped copies are produced. + defer crypto.Wipe(masterKey) // encrypt master key with password - passwordKek := crypto.DeriveKEK(input.Password, passwordSalt) + passwordKek := crypto.DeriveKEK(passwordBytes, passwordSalt) + defer crypto.Wipe(passwordKek) passwordEncryptedMasterKey, passwordNonce, err := crypto.EncryptMasterKey(passwordKek, masterKey) if err != nil { return nil, errors.AsInternalServerError("register: encrypt master key with password", err) @@ -209,6 +220,7 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { // encrypt master key with recovery key recoveryKek := crypto.DeriveKEK(recoveryKey, recoverySalt) + defer crypto.Wipe(recoveryKek) recoveryEncryptedMasterKey, recoveryNonce, err := crypto.EncryptMasterKey(recoveryKek, masterKey) if err != nil { return nil, errors.AsInternalServerError("register: encrypt master key with recovery key", err) @@ -216,12 +228,8 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { // Dual-encrypt the database credentials and persist them in the keyring so // login can re-open the user's database and reset can re-wrap them. - creds := dbconfig.FromConfig(config) - encryptedCreds, err := dbconfig.EncryptDBCredentials(input.Password, recoveryKey, creds) - if err != nil { - return nil, errors.AsInternalServerError("register: encrypt database credentials", err) - } - if err := s.dbCreds.Save(input.Username, encryptedCreds); err != nil { + creds := FromConfig(config) + if err := s.repo.SaveCredentials(input.Username, passwordBytes, recoveryKey, creds); err != nil { return nil, errors.AsInternalServerError("register: save database credentials", err) } @@ -245,8 +253,10 @@ func (s *Service) Register(input RegisterInput) (*RegisterResult, error) { return nil, errors.AsInternalServerError("register: create user", err) } - // return the original recovery key to the user so they can store it - return &RegisterResult{User: user, RecoveryKey: recoveryKey}, nil + // return the original recovery key to the user so they can store it. The + // []byte buffer is wiped on the way out; this string conversion is the one + // immutable copy Wails needs for serialization. + return &RegisterResult{User: user, RecoveryKey: string(recoveryKey)}, nil } // Login verifies the password, unwraps the master key with the password-derived @@ -258,22 +268,19 @@ func (s *Service) Login(input LoginInput) (bool, error) { return false, errors.ErrInvalidInput } - // Load the user's encrypted database credentials from the keyring. A - // missing entry means no such account exists. - blob, err := s.dbCreds.Load(input.Username) - if err != nil { - if stderrors.Is(err, dbconfig.ErrCredentialsNotFound) { - return false, errors.ErrUserNotFound - } - return false, errors.AsInternalServerError("login: load database credentials", err) - } + passwordBytes := []byte(input.Password) + defer crypto.Wipe(passwordBytes) - // Decrypt the credentials with the password-derived KEK. A wrong password + // Load and decrypt the user's database credentials with the password-derived + // KEK. A missing keyring entry means no such account exists; a wrong password // fails GCM authentication, which maps to the same user-facing error as the // password-hash check below. - creds, err := dbconfig.DecryptDBCredentials(input.Password, blob) + creds, err := s.repo.LoadCredentials(input.Username, passwordBytes, true) if err != nil { - return false, errors.ErrInvalidPassword + if stderrors.Is(err, ErrCredentialsNotFound) { + return false, errors.ErrUserNotFound + } + return false, errors.AsInternalServerError("login: load database credentials", err) } config := creds.ToConfig() @@ -294,7 +301,7 @@ func (s *Service) Login(input LoginInput) (bool, error) { } // comparing the password against the stored Argon2id PHC hash - ok, err := crypto.VerifyPasswordHash(input.Password, user.passwordHash) + ok, err := crypto.VerifyPasswordHash(passwordBytes, user.passwordHash) if err != nil || !ok { s.conn.Close() return false, errors.ErrInvalidPassword @@ -309,7 +316,9 @@ func (s *Service) Login(input LoginInput) (bool, error) { } // deriving the KEK from the password and the stored salt - kek := crypto.DeriveKEK(input.Password, material.PasswordSalt) + kek := crypto.DeriveKEK(passwordBytes, material.PasswordSalt) + // The KEK is only needed to unwrap the master key; scrub it afterwards. + defer crypto.Wipe(kek) // decrypting the master key masterKey, err := crypto.DecryptMasterKey(kek, material.PasswordMasterKey, material.PasswordNonce) @@ -341,17 +350,18 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro return nil, errors.ErrInvalidInput } - blob, err := s.dbCreds.Load(input.Username) - if err != nil { - if stderrors.Is(err, dbconfig.ErrCredentialsNotFound) { - return nil, errors.ErrUserNotFound - } - return nil, errors.AsInternalServerError("reset password: load database credentials", err) - } + recoveryKeyBytes := []byte(input.RecoveryKey) + defer crypto.Wipe(recoveryKeyBytes) + newPasswordBytes := []byte(input.NewPassword) + defer crypto.Wipe(newPasswordBytes) // The recovery key unwraps both the master key and the database credentials. - creds, err := dbconfig.DecryptDBCredentialsWithRecovery(input.RecoveryKey, blob) + // A missing keyring entry means no such account exists. + creds, err := s.repo.LoadCredentials(input.Username, recoveryKeyBytes, false) if err != nil { + if stderrors.Is(err, ErrCredentialsNotFound) { + return nil, errors.ErrUserNotFound + } return nil, errors.ErrInvalidRecoveryKey } config := creds.ToConfig() @@ -380,7 +390,7 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro } // Verify the recovery key against the stored Argon2id PHC hash. - ok, err := crypto.VerifyPasswordHash(input.RecoveryKey, user.recoveryKey) + ok, err := crypto.VerifyPasswordHash(recoveryKeyBytes, user.recoveryKey) if err != nil || !ok { s.conn.Close() return nil, errors.ErrInvalidRecoveryKey @@ -392,9 +402,10 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro s.conn.Close() return nil, errors.AsInternalServerError("reset password: generate recovery key", err) } + defer crypto.Wipe(newRecoveryKey) // hash the new password to store - hashedPassword, err := crypto.HashPassword(input.NewPassword) + hashedPassword, err := crypto.HashPassword(newPasswordBytes) if err != nil { s.conn.Close() return nil, errors.AsInternalServerError("reset password: hash password", err) @@ -416,15 +427,21 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro } // extract the original master key using the provided recovery key - recoveryKek := crypto.DeriveKEK(input.RecoveryKey, material.RecoverySalt) + recoveryKek := crypto.DeriveKEK(recoveryKeyBytes, 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) } + // This KEK is reassigned below, so scrub it now rather than deferring. + crypto.Wipe(recoveryKek) + // Reset does not sign the user in, so the unwrapped master key is transient + // and must be scrubbed once the re-wrapped copies are produced. + defer crypto.Wipe(masterKey) // generate the new encrypted master key using password - passwordKek := crypto.DeriveKEK(input.NewPassword, material.PasswordSalt) + passwordKek := crypto.DeriveKEK(newPasswordBytes, material.PasswordSalt) + defer crypto.Wipe(passwordKek) passwordEncryptedMasterKey, passwordNonce, err := crypto.EncryptMasterKey(passwordKek, masterKey) if err != nil { s.conn.Close() @@ -438,6 +455,7 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro s.conn.Close() return nil, errors.AsInternalServerError("reset password: encrypt master key with recovery key", err) } + crypto.Wipe(recoveryKek) // update the password and recovery key hashes err = s.repo.UpdateUserHashes( @@ -465,12 +483,7 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro // 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) - if err != nil { - s.conn.Close() - return nil, errors.AsInternalServerError("reset password: re-encrypt database credentials", err) - } - if err := s.dbCreds.Save(input.Username, encryptedCreds); err != nil { + if err := s.repo.SaveCredentials(input.Username, newPasswordBytes, newRecoveryKey, creds); err != nil { s.conn.Close() return nil, errors.AsInternalServerError("reset password: save database credentials", err) } @@ -484,7 +497,9 @@ func (s *Service) ResetPassword(input ResetPasswordInput) (*RegisterResult, erro } } - return &RegisterResult{User: user, RecoveryKey: newRecoveryKey}, nil + // The []byte buffer is wiped on the way out; this string conversion is the + // one immutable copy Wails needs for serialization. + return &RegisterResult{User: user, RecoveryKey: string(newRecoveryKey)}, nil } // Logout clears the in-memory session and closes the user's database @@ -557,8 +572,8 @@ 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 { + target := Storage(storage) + if target != StorageDatabase && target != StorageKeyring { return "", errors.ErrInvalidInput } @@ -581,13 +596,13 @@ func (s *Service) SetMasterKeyStorage(storage string) (string, error) { return "", errors.AsInternalServerError("set master key storage: load material", err) } - if target == masterkey.StorageKeyring { + if target == 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 { + if err := s.repo.SaveMasterKeyKeyring(s.session.Username, material); err != nil { return "", errors.AsInternalServerError("set master key storage: save to keyring", err) } - junk, err := masterkey.GenerateJunk() + junk, err := GenerateJunk() if err != nil { return "", errors.AsInternalServerError("set master key storage: generate junk", err) } @@ -600,7 +615,7 @@ func (s *Service) SetMasterKeyStorage(storage string) (string, error) { 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 { + if err := s.repo.DeleteMasterKeyKeyring(s.session.Username); err != nil { return "", errors.AsInternalServerError("set master key storage: delete keyring", err) } } @@ -611,27 +626,27 @@ func (s *Service) SetMasterKeyStorage(storage string) (string, error) { // 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) +func (s *Service) masterKeyStorage(username string) (Storage, error) { + exists, err := s.repo.MasterKeyKeyringExists(username) if err != nil { return "", err } if exists { - return masterkey.StorageKeyring, nil + return StorageKeyring, nil } - return masterkey.StorageDatabase, nil + return 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) +func (s *Service) loadMasterKeyMaterial(user *User) (*Material, error) { + exists, err := s.repo.MasterKeyKeyringExists(user.Username) if err != nil { return nil, err } if exists { - return s.mkey.Load(user.Username) + return s.repo.LoadMasterKeyKeyring(user.Username) } return user.MasterKeyMaterial(), nil } @@ -641,13 +656,38 @@ func (s *Service) loadMasterKeyMaterial(user *User) (*masterkey.Material, error) // 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) +func (s *Service) persistMasterKeyMaterial(ctx context.Context, user *User, material *Material) error { + exists, err := s.repo.MasterKeyKeyringExists(user.Username) if err != nil { return err } if exists { - return s.mkey.Save(user.Username, material) + return s.repo.SaveMasterKeyKeyring(user.Username, material) } return s.repo.UpdateMasterKeyMaterial(ctx, user.ID, material) } + +// SaveRecoveryKey opens a save file dialog and writes the recovery key to the +// selected location. It is the frontend-facing counterpart of the recovery-key +// flow in Register and ResetPassword: after either, the user downloads the key +// so it can be stored somewhere safe. The recovery key is passed in (the user +// is not signed in during these flows), never read from the session. +func (s *Service) SaveRecoveryKey(username, recoveryKey string) error { + filePath, err := dialog.SaveFile(s.ctx, dialog.Options{ + DefaultFilename: "recovery-key-" + username + ".txt", + Title: "Save Recovery Key", + FileFilterName: "Text Files (*.txt)", + FileFilterPattern: "*.txt", + }) + if err != nil { + return err + } + + // User cancelled the dialog + if filePath == "" { + return nil + } + + // Write the recovery key to the file + return os.WriteFile(filePath, []byte(recoveryKey), 0600) +} diff --git a/internal/features/dbconfig/crypto.go b/internal/features/dbconfig/crypto.go deleted file mode 100644 index 679996e..0000000 --- a/internal/features/dbconfig/crypto.go +++ /dev/null @@ -1,97 +0,0 @@ -package dbconfig - -import ( - "encoding/json" - - "ayo/internal/shared/crypto" -) - -// encryptedBlob is the JSON shape persisted in the keyring. The credentials are -// wrapped twice, mirroring the master key pattern: once with a KEK derived from -// the password and once with a KEK derived from the recovery key, each with its -// own random salt. Each ciphertext carries its own embedded nonce (see -// crypto.EncryptData), so a password reset can re-wrap credentials using the -// recovery-key copy without the old password. -type encryptedBlob struct { - PasswordSalt []byte `json:"PasswordSalt"` - PasswordEncrypted []byte `json:"PasswordEncrypted"` - RecoverySalt []byte `json:"RecoverySalt"` - RecoveryEncrypted []byte `json:"RecoveryEncrypted"` -} - -// EncryptDBCredentials serializes creds and wraps them with both the -// password-derived and recovery-key-derived KEKs. The returned blob is the JSON -// form ready to persist in the keyring. -func EncryptDBCredentials(password, recoveryKey string, creds DBCredentials) ([]byte, error) { - plaintext, err := json.Marshal(creds) - if err != nil { - return nil, err - } - - passwordSalt, err := crypto.GenerateSalt() - if err != nil { - return nil, err - } - passwordEncrypted, err := crypto.EncryptData(crypto.DeriveKEK(password, passwordSalt), plaintext) - if err != nil { - return nil, err - } - - recoverySalt, err := crypto.GenerateSalt() - if err != nil { - return nil, err - } - recoveryEncrypted, err := crypto.EncryptData(crypto.DeriveKEK(recoveryKey, recoverySalt), plaintext) - if err != nil { - return nil, err - } - - return json.Marshal(encryptedBlob{ - PasswordSalt: passwordSalt, - PasswordEncrypted: passwordEncrypted, - RecoverySalt: recoverySalt, - RecoveryEncrypted: recoveryEncrypted, - }) -} - -// DecryptDBCredentials unwraps a blob previously produced by -// EncryptDBCredentials using the password-derived KEK. A wrong password fails -// GCM authentication and returns an error. -func DecryptDBCredentials(password string, blob []byte) (DBCredentials, error) { - return decrypt(password, blob, true) -} - -// DecryptDBCredentialsWithRecovery unwraps a blob using the recovery-key-derived -// KEK. Used by the password-reset flow to recover credentials without the old -// password. -func DecryptDBCredentialsWithRecovery(recoveryKey string, blob []byte) (DBCredentials, error) { - return decrypt(recoveryKey, blob, false) -} - -func decrypt(secret string, blob []byte, fromPassword bool) (DBCredentials, error) { - var e encryptedBlob - if err := json.Unmarshal(blob, &e); err != nil { - return DBCredentials{}, err - } - - var kek []byte - var encrypted []byte - if fromPassword { - kek = crypto.DeriveKEK(secret, e.PasswordSalt) - encrypted = e.PasswordEncrypted - } else { - kek = crypto.DeriveKEK(secret, e.RecoverySalt) - encrypted = e.RecoveryEncrypted - } - - plaintext, err := crypto.DecryptData(kek, encrypted) - if err != nil { - return DBCredentials{}, err - } - - var creds DBCredentials - if err := json.Unmarshal(plaintext, &creds); err != nil { - return DBCredentials{}, err - } - return creds, nil -} diff --git a/internal/features/dbconfig/crypto_test.go b/internal/features/dbconfig/crypto_test.go deleted file mode 100644 index 261682b..0000000 --- a/internal/features/dbconfig/crypto_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package dbconfig - -import ( - "reflect" - "testing" - - dbclient "ayo/internal/clients/db" -) - -func TestEncryptDecryptRoundTrip(t *testing.T) { - creds := DBCredentials{ - Type: dbclient.PostgreSQL, - Host: "localhost", - Port: 5432, - Database: "ayo", - Username: "alice", - Password: "s3cret!Pass", - } - const password = "Sup3r&secure" - const recoveryKey = "recovery-key-123" - - blob, err := EncryptDBCredentials(password, recoveryKey, creds) - if err != nil { - t.Fatalf("encrypt: %v", err) - } - - got, err := DecryptDBCredentials(password, blob) - if err != nil { - t.Fatalf("decrypt with password: %v", err) - } - if !reflect.DeepEqual(got, creds) { - t.Fatalf("password round-trip mismatch:\n got %+v\nwant %+v", got, creds) - } - - got, err = DecryptDBCredentialsWithRecovery(recoveryKey, blob) - if err != nil { - t.Fatalf("decrypt with recovery key: %v", err) - } - if !reflect.DeepEqual(got, creds) { - t.Fatalf("recovery round-trip mismatch:\n got %+v\nwant %+v", got, creds) - } -} - -func TestDecryptWrongSecretFails(t *testing.T) { - creds := DBCredentials{Type: dbclient.SQLite, Path: "/tmp/alice.db"} - blob, err := EncryptDBCredentials("Right#Pass1", "right-recovery", creds) - if err != nil { - t.Fatalf("encrypt: %v", err) - } - - if _, err := DecryptDBCredentials("Wrong#Pass1", blob); err == nil { - t.Fatal("expected error decrypting with wrong password") - } - if _, err := DecryptDBCredentialsWithRecovery("wrong-recovery", blob); err == nil { - t.Fatal("expected error decrypting with wrong recovery key") - } -} diff --git a/internal/features/dbconfig/model.go b/internal/features/dbconfig/model.go deleted file mode 100644 index 28d06e8..0000000 --- a/internal/features/dbconfig/model.go +++ /dev/null @@ -1,47 +0,0 @@ -package dbconfig - -import ( - dbclient "ayo/internal/clients/db" -) - -// DBCredentials is the plaintext database configuration for one account. It is -// serialized to JSON, dual-encrypted (password-KEK + recovery-KEK) and stored -// in the OS keyring; only the encrypted blob ever persists. The password is -// stored here too (it is needed to open the connection at login) but is never -// exposed to the frontend. -type DBCredentials struct { - Type dbclient.Dialect `json:"Type"` - Path string `json:"Path,omitempty"` - Host string `json:"Host,omitempty"` - Port int `json:"Port,omitempty"` - Database string `json:"Database,omitempty"` - Username string `json:"Username,omitempty"` - Password string `json:"Password,omitempty"` -} - -// ToConfig converts the stored credentials into a client config usable with -// dbclient.NewClient / dbclient.Validate. -func (d DBCredentials) ToConfig() dbclient.Config { - return dbclient.Config{ - Type: d.Type, - Path: d.Path, - Host: d.Host, - Port: d.Port, - Database: d.Database, - Username: d.Username, - Password: d.Password, - } -} - -// FromConfig builds stored credentials from a client config. -func FromConfig(c dbclient.Config) DBCredentials { - return DBCredentials{ - Type: c.Type, - Path: c.Path, - Host: c.Host, - Port: c.Port, - Database: c.Database, - Username: c.Username, - Password: c.Password, - } -} diff --git a/internal/features/dbconfig/repository.go b/internal/features/dbconfig/repository.go deleted file mode 100644 index 799a9ad..0000000 --- a/internal/features/dbconfig/repository.go +++ /dev/null @@ -1,72 +0,0 @@ -package dbconfig - -import ( - "encoding/base64" - "errors" - "fmt" - - "ayo/internal/platform/keyring" -) - -// ErrCredentialsNotFound is returned by Load when no database-credentials entry -// exists for the user. It is an internal marker (mapped by the auth service to -// ErrUserNotFound) rather than a user-facing message. -var ErrCredentialsNotFound = errors.New("database credentials not found in keyring") - -// Repository abstracts persistence of the encrypted database-credentials blob -// in the OS keyring. It mirrors the settings feature's keyring repository: the -// blob is base64-encoded and stored under the "ayo" service, keyed by user. -type Repository interface { - // Load returns the encrypted credentials blob, or ErrCredentialsNotFound - // when nothing has been saved yet. - Load(username string) ([]byte, error) - // Save replaces the encrypted credentials blob for the given user. - Save(username string, data []byte) error - // Exists reports whether a database-credentials entry is stored for the - // user. It is the machine-level account marker: every registered account - // saves an entry and never deletes it, so its presence means a username is - // already taken on this device. - 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 -// database credentials, keeping it separate from the "ayo" entries used by -// settings. -func keyringUser(username string) string { - return "dbcreds_" + username -} - -func (r *repository) Load(username string) ([]byte, error) { - encoded, err := keyring.Get("ayo", keyringUser(username)) - if err != nil { - if keyring.IsNotFound(err) { - return nil, ErrCredentialsNotFound - } - return nil, fmt.Errorf("load database credentials from keyring: %w", err) - } - - decoded, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return nil, fmt.Errorf("decode database credentials blob: %w", err) - } - return decoded, nil -} - -func (r *repository) Save(username string, data []byte) error { - encoded := base64.StdEncoding.EncodeToString(data) - if err := keyring.Set("ayo", keyringUser(username), encoded); err != nil { - return fmt.Errorf("save database credentials to keyring: %w", err) - } - return nil -} - -func (r *repository) Exists(username string) (bool, error) { - return keyring.Exists("ayo", keyringUser(username)) -} diff --git a/internal/features/masterkey/model.go b/internal/features/masterkey/model.go deleted file mode 100644 index 96d8b38..0000000 --- a/internal/features/masterkey/model.go +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index a821d6b..0000000 --- a/internal/features/masterkey/repository.go +++ /dev/null @@ -1,141 +0,0 @@ -package masterkey - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - - "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 keyring.IsNotFound(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) { - return keyring.Exists("ayo", keyringUser(username)) -} - -// 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/features/recovery/service.go b/internal/features/recovery/service.go deleted file mode 100644 index 957c389..0000000 --- a/internal/features/recovery/service.go +++ /dev/null @@ -1,46 +0,0 @@ -package recovery - -import ( - "context" - "os" - - "ayo/internal/platform/dialog" -) - -// Service handles saving the user's recovery key to a file. It is the -// frontend-facing counterpart of the recovery-key flow in auth: after -// registration or a password reset the user downloads the key via -// SaveRecoveryKey so it can be stored somewhere safe. -type Service struct { - ctx context.Context -} - -func NewService() *Service { - return &Service{} -} - -// Startup is called by Wails on application startup -func (s *Service) Startup(ctx context.Context) { - s.ctx = ctx -} - -// SaveRecoveryKey opens a save file dialog and saves the recovery key to the selected location -func (s *Service) SaveRecoveryKey(username, recoveryKey string) error { - filePath, err := dialog.SaveFile(s.ctx, dialog.Options{ - DefaultFilename: "recovery-key-" + username + ".txt", - Title: "Save Recovery Key", - FileFilterName: "Text Files (*.txt)", - FileFilterPattern: "*.txt", - }) - if err != nil { - return err - } - - // User cancelled the dialog - if filePath == "" { - return nil - } - - // Write the recovery key to the file - return os.WriteFile(filePath, []byte(recoveryKey), 0600) -} diff --git a/internal/features/settings/service.go b/internal/features/settings/service.go index fc7e9ca..1554466 100644 --- a/internal/features/settings/service.go +++ b/internal/features/settings/service.go @@ -6,8 +6,8 @@ import ( dbclient "ayo/internal/clients/db" "ayo/internal/features/auth" - "ayo/internal/platform/dialog" "ayo/internal/shared/crypto" + "ayo/internal/shared/dialog" "ayo/internal/shared/errors" "github.com/go-playground/validator/v10" diff --git a/internal/features/upload/service.go b/internal/features/upload/service.go index 4cb1e60..815f497 100644 --- a/internal/features/upload/service.go +++ b/internal/features/upload/service.go @@ -9,8 +9,8 @@ import ( "ayo/internal/clients/storage" "ayo/internal/features/auth" "ayo/internal/features/settings" - "ayo/internal/platform/dialog" "ayo/internal/platform/queue" + "ayo/internal/shared/dialog" "ayo/internal/shared/errors" "github.com/go-playground/validator/v10" diff --git a/internal/shared/crypto/encryption.go b/internal/shared/crypto/encryption.go index c6c11c5..324598b 100644 --- a/internal/shared/crypto/encryption.go +++ b/internal/shared/crypto/encryption.go @@ -29,6 +29,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/binary" + "encoding/json" "errors" "fmt" "io" @@ -70,17 +71,19 @@ var argon2Params = &argon2id.Params{ } // GenerateRecoveryKey returns a new random 256-bit recovery key encoded as a -// URL-safe base64 string. The user is shown this value exactly once (at -// registration/reset) and must store it somewhere safe. -func GenerateRecoveryKey() (string, error) { +// URL-safe base64 string. The returned []byte is a zeroable buffer so the +// caller can scrub it (see Wipe) after showing the value; it must be converted +// to a string only at the point of display. The user is shown this value +// exactly once (at registration/reset) and must store it somewhere safe. +func GenerateRecoveryKey() ([]byte, error) { const size = 32 // 256 bits b := make([]byte, size) if _, err := rand.Read(b); err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) + return nil, fmt.Errorf("failed to generate random bytes: %w", err) } - return base64.RawURLEncoding.EncodeToString(b), nil + return []byte(base64.RawURLEncoding.EncodeToString(b)), nil } // GenerateSalt returns a random salt for use with DeriveKEK. Salts are @@ -185,9 +188,9 @@ func DecryptMasterKey(kek []byte, encryptedMasterKey []byte, nonce []byte) ([]by // DeriveKEK derives a Key Encryption Key from a password and salt using // Argon2id. The result depends on argon2Params, so those parameters must not // change after keys have been persisted. -func DeriveKEK(password string, salt []byte) []byte { +func DeriveKEK(password []byte, salt []byte) []byte { kek := argon2.IDKey( - []byte(password), + password, salt, argon2Params.Iterations, argon2Params.Memory, @@ -261,6 +264,83 @@ func DecryptData(key []byte, ciphertext []byte) ([]byte, error) { return aead.Open(nil, nonce, encryptedData, nil) } +// dualEncryptedBlob is the JSON shape persisted for a value wrapped twice: +// once with a KEK derived from a password and once with a KEK derived from a +// recovery key, each with its own random salt. Each ciphertext carries its own +// embedded nonce (see EncryptData), so a password reset can re-wrap the value +// using the recovery-key copy without the old password. +type dualEncryptedBlob struct { + PasswordSalt []byte `json:"PasswordSalt"` + PasswordEncrypted []byte `json:"PasswordEncrypted"` + RecoverySalt []byte `json:"RecoverySalt"` + RecoveryEncrypted []byte `json:"RecoveryEncrypted"` +} + +// DualEncrypt wraps plaintext with both a password-derived and a +// recovery-key-derived KEK using AES-256-GCM, mirroring the master-key pattern. +// The returned blob is the JSON form ready to persist. password and recoveryKey +// must be mutable copies of the secrets (see Wipe); the derived KEKs are +// scrubbed before returning. The caller owns plaintext and should wipe it. +func DualEncrypt(plaintext, password, recoveryKey []byte) ([]byte, error) { + passwordSalt, err := GenerateSalt() + if err != nil { + return nil, err + } + passwordKek := DeriveKEK(password, passwordSalt) + defer Wipe(passwordKek) + passwordEncrypted, err := EncryptData(passwordKek, plaintext) + if err != nil { + return nil, err + } + + recoverySalt, err := GenerateSalt() + if err != nil { + return nil, err + } + recoveryKek := DeriveKEK(recoveryKey, recoverySalt) + defer Wipe(recoveryKek) + recoveryEncrypted, err := EncryptData(recoveryKek, plaintext) + if err != nil { + return nil, err + } + + return json.Marshal(dualEncryptedBlob{ + PasswordSalt: passwordSalt, + PasswordEncrypted: passwordEncrypted, + RecoverySalt: recoverySalt, + RecoveryEncrypted: recoveryEncrypted, + }) +} + +// DualDecrypt unwraps a blob previously produced by DualEncrypt. fromPassword +// selects the password-derived KEK (used on login); otherwise the +// recovery-key-derived KEK is used (used on password reset). A wrong secret +// fails GCM authentication and returns an error. secret must be a mutable copy +// (see Wipe); the transient KEK and plaintext are scrubbed before returning. +func DualDecrypt(blob, secret []byte, fromPassword bool) ([]byte, error) { + var e dualEncryptedBlob + if err := json.Unmarshal(blob, &e); err != nil { + return nil, err + } + + var kek []byte + var encrypted []byte + if fromPassword { + kek = DeriveKEK(secret, e.PasswordSalt) + encrypted = e.PasswordEncrypted + } else { + kek = DeriveKEK(secret, e.RecoverySalt) + encrypted = e.RecoveryEncrypted + } + defer Wipe(kek) + + plaintext, err := DecryptData(kek, encrypted) + if err != nil { + return nil, err + } + return plaintext, nil +} + // StreamEncrypt encrypts data from reader to writer in fixed-size chunks, // streaming through memory without loading the entire file. Each chunk is // encrypted with AES-256-GCM using a derived nonce (base nonce + counter), diff --git a/internal/shared/crypto/encryption_test.go b/internal/shared/crypto/encryption_test.go deleted file mode 100644 index 68844f2..0000000 --- a/internal/shared/crypto/encryption_test.go +++ /dev/null @@ -1,233 +0,0 @@ -package crypto - -import ( - "bytes" - "testing" -) - -// testPayload returns a deterministic plaintext of the given size. -func testPayload(size int) []byte { - b := make([]byte, size) - for i := range b { - b[i] = byte(i * 7) - } - return b -} - -// envelopeEncrypt encrypts plaintext end to end and returns the wrapped DEK, key -// nonce, file nonce and the encrypted blob. -func envelopeEncrypt(t *testing.T, masterKey, plaintext []byte) (wrappedDEK, keyNonce, fileNonce, blob []byte) { - t.Helper() - - dek, err := GenerateDEK() - if err != nil { - t.Fatalf("GenerateDEK: %v", err) - } - - wrappedDEK, keyNonce, err = WrapDEK(masterKey, dek) - if err != nil { - t.Fatalf("WrapDEK: %v", err) - } - - fileNonce, err = GenerateNonce() - if err != nil { - t.Fatalf("GenerateNonce: %v", err) - } - - var out bytes.Buffer - if err := StreamEncrypt(bytes.NewReader(plaintext), &out, dek, fileNonce); err != nil { - t.Fatalf("StreamEncrypt: %v", err) - } - - return wrappedDEK, keyNonce, fileNonce, out.Bytes() -} - -// envelopeDecrypt unwraps the DEK and decrypts the blob back to plaintext. -func envelopeDecrypt(t *testing.T, masterKey, wrappedDEK, keyNonce, fileNonce, blob []byte) []byte { - t.Helper() - - dek, err := UnwrapDEK(masterKey, wrappedDEK, keyNonce) - if err != nil { - t.Fatalf("UnwrapDEK: %v", err) - } - - var out bytes.Buffer - if err := StreamDecrypt(bytes.NewReader(blob), &out, dek, fileNonce); err != nil { - t.Fatalf("StreamDecrypt: %v", err) - } - - return out.Bytes() -} - -// TestStreamEnvelopeRoundTrip encrypts a multi-chunk payload (larger than one -// 64KB chunk) plus a single-byte and empty payload through the full envelope -// pipeline and verifies the decrypted output matches the input. -func TestStreamEnvelopeRoundTrip(t *testing.T) { - masterKey, err := GenerateMasterKey() - if err != nil { - t.Fatalf("GenerateMasterKey: %v", err) - } - - sizes := []int{0, 1, ChunkSize, 3*ChunkSize + 4096} - for _, size := range sizes { - t.Run("size", func(t *testing.T) { - plaintext := testPayload(size) - - wrappedDEK, keyNonce, fileNonce, blob := envelopeEncrypt(t, masterKey, plaintext) - got := envelopeDecrypt(t, masterKey, wrappedDEK, keyNonce, fileNonce, blob) - - if !bytes.Equal(got, plaintext) { - t.Fatalf("round trip mismatch: got %d bytes, want %d", len(got), len(plaintext)) - } - }) - } -} - -// TestGenerateDEKIsFreshAndSized verifies each DEK is 32 bytes and that two -// generated keys are distinct (a brand-new DEK per file). -func TestGenerateDEKIsFreshAndSized(t *testing.T) { - a, err := GenerateDEK() - if err != nil { - t.Fatalf("GenerateDEK: %v", err) - } - b, err := GenerateDEK() - if err != nil { - t.Fatalf("GenerateDEK: %v", err) - } - - if len(a) != KeySize { - t.Fatalf("DEK size = %d, want %d", len(a), KeySize) - } - if bytes.Equal(a, b) { - t.Fatal("two generated DEKs are identical") - } -} - -// TestFreshEnvelopePerEncryption verifies that encrypting the same plaintext -// twice with the same master key produces a different wrapped DEK and file -// nonce (a fresh DEK and nonce are generated every single time). -func TestFreshEnvelopePerEncryption(t *testing.T) { - masterKey, err := GenerateMasterKey() - if err != nil { - t.Fatalf("GenerateMasterKey: %v", err) - } - plaintext := testPayload(ChunkSize + 1) - - dekA, keyNonceA, fileNonceA, blobA := envelopeEncrypt(t, masterKey, plaintext) - dekB, keyNonceB, fileNonceB, blobB := envelopeEncrypt(t, masterKey, plaintext) - - if bytes.Equal(dekA, dekB) { - t.Fatal("wrapped DEKs are identical across two encryptions") - } - if bytes.Equal(keyNonceA, keyNonceB) { - t.Fatal("key nonces are identical across two encryptions") - } - if bytes.Equal(fileNonceA, fileNonceB) { - t.Fatal("file nonces are identical across two encryptions") - } - if bytes.Equal(blobA, blobB) { - t.Fatal("encrypted blobs are identical across two encryptions") - } - - if got := envelopeDecrypt(t, masterKey, dekA, keyNonceA, fileNonceA, blobA); !bytes.Equal(got, plaintext) { - t.Fatal("first envelope failed to decrypt") - } - if got := envelopeDecrypt(t, masterKey, dekB, keyNonceB, fileNonceB, blobB); !bytes.Equal(got, plaintext) { - t.Fatal("second envelope failed to decrypt") - } -} - -// TestCorruptKeyAuthTagFails verifies that a corrupted wrapped DEK (its -// authentication tag or ciphertext) makes UnwrapDEK fail, so decryption aborts -// securely before any payload bytes are processed. -func TestCorruptKeyAuthTagFails(t *testing.T) { - masterKey, err := GenerateMasterKey() - if err != nil { - t.Fatalf("GenerateMasterKey: %v", err) - } - plaintext := testPayload(ChunkSize) - - wrappedDEK, keyNonce, _, _ := envelopeEncrypt(t, masterKey, plaintext) - - // Corrupt one byte in the wrapped DEK ciphertext and again in its tag. - for _, idx := range []int{0, len(wrappedDEK) - 1} { - corrupted := make([]byte, len(wrappedDEK)) - copy(corrupted, wrappedDEK) - corrupted[idx] ^= 0x01 - - if _, err := UnwrapDEK(masterKey, corrupted, keyNonce); err == nil { - t.Fatalf("UnwrapDEK accepted corrupted wrapped DEK at byte %d", idx) - } - } - - // Corrupt the key nonce. - badNonce := make([]byte, len(keyNonce)) - copy(badNonce, keyNonce) - badNonce[0] ^= 0x01 - if _, err := UnwrapDEK(masterKey, wrappedDEK, badNonce); err == nil { - t.Fatal("UnwrapDEK accepted corrupted key nonce") - } -} - -// TestCorruptFileAuthTagFails verifies that a corrupted payload chunk (its -// ciphertext or authentication tag) makes StreamDecrypt fail securely. -func TestCorruptFileAuthTagFails(t *testing.T) { - masterKey, err := GenerateMasterKey() - if err != nil { - t.Fatalf("GenerateMasterKey: %v", err) - } - // Use several chunks so corruption can hit a body chunk and a trailing tag. - plaintext := testPayload(3 * ChunkSize) - - wrappedDEK, keyNonce, fileNonce, blob := envelopeEncrypt(t, masterKey, plaintext) - dek, err := UnwrapDEK(masterKey, wrappedDEK, keyNonce) - if err != nil { - t.Fatalf("UnwrapDEK: %v", err) - } - - // Corrupt one byte inside a chunk ciphertext and one byte in a tag. - for _, idx := range []int{ChunkSize / 2, len(blob) - 1} { - corrupted := make([]byte, len(blob)) - copy(corrupted, blob) - corrupted[idx] ^= 0x01 - - var out bytes.Buffer - if err := StreamDecrypt(bytes.NewReader(corrupted), &out, dek, fileNonce); err == nil { - t.Fatalf("StreamDecrypt accepted corrupted blob at byte %d", idx) - } - } -} - -// TestMasterKeyCannotDecryptPayload verifies the master key is never used to -// encrypt payload bytes: feeding the master key in place of the DEK must fail -// to decrypt the blob. -func TestMasterKeyCannotDecryptPayload(t *testing.T) { - masterKey, err := GenerateMasterKey() - if err != nil { - t.Fatalf("GenerateMasterKey: %v", err) - } - plaintext := testPayload(ChunkSize + 1) - - _, _, fileNonce, blob := envelopeEncrypt(t, masterKey, plaintext) - - // Use the master key directly as the payload key: this must not decrypt. - var out bytes.Buffer - if err := StreamDecrypt(bytes.NewReader(blob), &out, masterKey, fileNonce); err == nil { - t.Fatal("StreamDecrypt decrypted payload with the master key; payload is not DEK-encrypted") - } -} - -// TestStreamRequiresNonceSize verifies a wrong-sized file nonce is rejected -// up front rather than silently producing a corrupted stream. -func TestStreamRequiresNonceSize(t *testing.T) { - dek, err := GenerateDEK() - if err != nil { - t.Fatalf("GenerateDEK: %v", err) - } - - badNonce := make([]byte, 16) - var out bytes.Buffer - if err := StreamEncrypt(bytes.NewReader(testPayload(10)), &out, dek, badNonce); err == nil { - t.Fatal("StreamEncrypt accepted a 16-byte file nonce") - } -} diff --git a/internal/shared/crypto/hash.go b/internal/shared/crypto/hash.go index 4c55eaa..057f7bf 100644 --- a/internal/shared/crypto/hash.go +++ b/internal/shared/crypto/hash.go @@ -7,15 +7,17 @@ import ( // HashPassword hashes a password (or recovery key) with Argon2id and returns // the self-describing PHC string to persist, e.g. // "$argon2id$v=19$m=65536,t=3,p=4$$". The random salt is embedded -// in the string, so no separate salt column is needed. -func HashPassword(password string) (string, error) { - return argon2id.CreateHash(password, argon2Params) +// in the string, so no separate salt column is needed. password must be a +// mutable copy of the secret so the caller can wipe it afterwards (see Wipe). +func HashPassword(password []byte) (string, error) { + return argon2id.CreateHash(string(password), argon2Params) } // VerifyPasswordHash checks a plaintext password against an Argon2id PHC hash // produced by HashPassword. Parameters embedded in the hash are used, so it // keeps working even if argon2Params later changes. A malformed or foreign -// hash returns false with an error. -func VerifyPasswordHash(password, encodedHash string) (bool, error) { - return argon2id.ComparePasswordAndHash(password, encodedHash) +// hash returns false with an error. password must be a mutable copy of the +// secret so the caller can wipe it afterwards (see Wipe). +func VerifyPasswordHash(password []byte, encodedHash string) (bool, error) { + return argon2id.ComparePasswordAndHash(string(password), encodedHash) } diff --git a/internal/shared/crypto/wipe.go b/internal/shared/crypto/wipe.go new file mode 100644 index 0000000..9f88dc7 --- /dev/null +++ b/internal/shared/crypto/wipe.go @@ -0,0 +1,13 @@ +package crypto + +// Wipe overwrites every byte of each buffer with zeros so that plaintext key +// material does not linger on the Go heap after it is no longer needed. Go's +// garbage collector reuses memory without scrubbing it, so callers that hold +// secrets must zero them explicitly. Wiping a nil or empty buffer is a no-op. +func Wipe(bufs ...[]byte) { + for _, b := range bufs { + for i := range b { + b[i] = 0 + } + } +} diff --git a/internal/platform/dialog/dialog.go b/internal/shared/dialog/dialog.go similarity index 92% rename from internal/platform/dialog/dialog.go rename to internal/shared/dialog/dialog.go index e5bc6de..857b5ce 100644 --- a/internal/platform/dialog/dialog.go +++ b/internal/shared/dialog/dialog.go @@ -1,8 +1,8 @@ // Package dialog wraps Wails' native desktop dialogs behind a small, // platform-independent API. // -// It belongs to the platform tier: it hides the Wails runtime dependency so -// feature packages (e.g. recovery) can prompt the user for file paths without +// It lives in the shared tier: it hides the Wails runtime dependency so feature +// packages (e.g. auth, upload) can prompt the user for file paths without // importing the runtime themselves. Add open-dir / open-file wrappers here as // features need them. package dialog diff --git a/main.go b/main.go index 3e1d001..5022666 100644 --- a/main.go +++ b/main.go @@ -7,10 +7,7 @@ import ( dbclient "ayo/internal/clients/db" "ayo/internal/clients/storage" "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" "ayo/internal/platform/queue" @@ -66,17 +63,11 @@ func main() { // the in-memory session, the master key and the active database connection, // 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. 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() - masterkeyRepository := masterkey.NewRepository() - authService := auth.NewService(conn, dbconfigRepository, masterkeyRepository) - - // Recovery service: native save dialogs for downloading the recovery key. - recoveryService := recovery.NewService() + // Database credentials are persisted in the OS keyring through the auth + // feature's keyring helpers. The encrypted master-key material can likewise + // live in the OS keyring (account-scoped "mkey_{username}") or in the users + // table; the auth service migrates between the two via Get/SetMasterKeyStorage. + authService := auth.NewService(conn) // Settings service: stores per-user settings in the OS keyring, encrypted // with the session master key. Provider configs are validated through the @@ -130,7 +121,7 @@ func main() { OnStartup: func(ctx context.Context) { app.startup(ctx) // Only services that need the Wails context receive it here. - recoveryService.Startup(ctx) + authService.Startup(ctx) uploadService.Startup(ctx) settingsService.Startup(ctx) }, @@ -157,7 +148,6 @@ func main() { Bind: []interface{}{ app, authService, - recoveryService, settingsService, uploadService, homeService,