Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 32 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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).
2 changes: 1 addition & 1 deletion frontend/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/pages/auth/Register/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/pages/auth/Reset/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 5 additions & 0 deletions frontend/wailsjs/go/auth/Service.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<db.Client>;

Expand All @@ -21,4 +22,8 @@ export function RequireSession():Promise<auth.Session>;

export function ResetPassword(arg1:auth.ResetPasswordInput):Promise<auth.RegisterResult>;

export function SaveRecoveryKey(arg1:string,arg2:string):Promise<void>;

export function SetMasterKeyStorage(arg1:string):Promise<string>;

export function Startup(arg1:context.Context):Promise<void>;
8 changes: 8 additions & 0 deletions frontend/wailsjs/go/auth/Service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
7 changes: 0 additions & 7 deletions frontend/wailsjs/go/recovery/Service.d.ts

This file was deleted.

11 changes: 0 additions & 11 deletions frontend/wailsjs/go/recovery/Service.js

This file was deleted.

80 changes: 76 additions & 4 deletions internal/features/auth/model.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
}
Loading
Loading