diff --git a/internal/store/account.go b/internal/store/account.go new file mode 100644 index 0000000..0cde267 --- /dev/null +++ b/internal/store/account.go @@ -0,0 +1,141 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "git.dcglab.co.uk/steve/emcli/internal/crypto" +) + +var ErrAccountNotFound = errors.New("account not found") + +// Account is the decrypted, in-memory view of a configured account. +type Account struct { + ID int64 + Name string + Mode string // RO | RW + IMAPHost string + IMAPPort int + IMAPSecurity string // tls | starttls + AuthType string // password | oauth2 + Username string + Password string // decrypted; empty in ListAccounts + WhitelistInEnabled bool + WhitelistOutEnabled bool + SubjectRegex string + ProcessBacklog bool +} + +func (s *Store) AddAccount(a Account) (int64, error) { + var encPw []byte + if a.Password != "" { + b, err := crypto.Seal(s.key, []byte(a.Password)) + if err != nil { + return 0, err + } + encPw = b + } + res, err := s.db.Exec(` + INSERT INTO accounts + (name,mode,imap_host,imap_port,imap_security,auth_type,username, + enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + a.Name, a.Mode, a.IMAPHost, a.IMAPPort, a.IMAPSecurity, a.AuthType, a.Username, + encPw, b2i(a.WhitelistInEnabled), b2i(a.WhitelistOutEnabled), + nullStr(a.SubjectRegex), b2i(a.ProcessBacklog)) + if err != nil { + return 0, fmt.Errorf("insert account: %w", err) + } + return res.LastInsertId() +} + +func (s *Store) GetAccount(name string) (Account, error) { + row := s.db.QueryRow(` + SELECT id,name,mode,imap_host,imap_port,imap_security,auth_type,username, + enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog + FROM accounts WHERE name = ?`, name) + a, encPw, err := scanAccount(row) + if errors.Is(err, sql.ErrNoRows) { + return Account{}, ErrAccountNotFound + } + if err != nil { + return Account{}, err + } + if len(encPw) > 0 { + pw, err := crypto.Open(s.key, encPw) + if err != nil { + return Account{}, fmt.Errorf("decrypt password (wrong EMCLI_KEY?): %w", err) + } + a.Password = string(pw) + } + return a, nil +} + +func (s *Store) ListAccounts() ([]Account, error) { + rows, err := s.db.Query(` + SELECT id,name,mode,imap_host,imap_port,imap_security,auth_type,username, + enc_password,whitelist_in_enabled,whitelist_out_enabled,subject_regex,process_backlog + FROM accounts ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Account + for rows.Next() { + a, _, err := scanAccount(rows) // secrets discarded + if err != nil { + return nil, err + } + out = append(out, a) + } + return out, rows.Err() +} + +func (s *Store) DeleteAccount(name string) error { + res, err := s.db.Exec("DELETE FROM accounts WHERE name = ?", name) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrAccountNotFound + } + return nil +} + +// scanner is satisfied by *sql.Row and *sql.Rows. +type scanner interface{ Scan(dest ...any) error } + +func scanAccount(sc scanner) (Account, []byte, error) { + var ( + a Account + encPw []byte + subj sql.NullString + wlIn, wlOut int + backlog int + ) + err := sc.Scan(&a.ID, &a.Name, &a.Mode, &a.IMAPHost, &a.IMAPPort, &a.IMAPSecurity, + &a.AuthType, &a.Username, &encPw, &wlIn, &wlOut, &subj, &backlog) + if err != nil { + return Account{}, nil, err + } + a.WhitelistInEnabled = wlIn != 0 + a.WhitelistOutEnabled = wlOut != 0 + a.ProcessBacklog = backlog != 0 + a.SubjectRegex = subj.String + return a, encPw, nil +} + +func b2i(b bool) int { + if b { + return 1 + } + return 0 +} + +func nullStr(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/internal/store/account_test.go b/internal/store/account_test.go new file mode 100644 index 0000000..1025405 --- /dev/null +++ b/internal/store/account_test.go @@ -0,0 +1,67 @@ +package store + +import ( + "errors" + "testing" +) + +func sampleAccount() Account { + return Account{ + Name: "work", Mode: "RO", + IMAPHost: "imap.example.com", IMAPPort: 993, IMAPSecurity: "tls", + AuthType: "password", Username: "me@example.com", + Password: "s3cr3t", SubjectRegex: "", + } +} + +func TestAddGetAccountDecryptsSecret(t *testing.T) { + s := openTemp(t) + id, err := s.AddAccount(sampleAccount()) + if err != nil { + t.Fatalf("AddAccount: %v", err) + } + if id == 0 { + t.Fatal("want non-zero id") + } + got, err := s.GetAccount("work") + if err != nil { + t.Fatalf("GetAccount: %v", err) + } + if got.Password != "s3cr3t" { + t.Fatalf("password not decrypted: %q", got.Password) + } + if got.Mode != "RO" || got.IMAPPort != 993 { + t.Fatalf("fields wrong: %+v", got) + } +} + +func TestPasswordStoredEncrypted(t *testing.T) { + s := openTemp(t) + _, _ = s.AddAccount(sampleAccount()) + var blob []byte + if err := s.db.QueryRow("SELECT enc_password FROM accounts WHERE name='work'").Scan(&blob); err != nil { + t.Fatalf("query: %v", err) + } + if string(blob) == "s3cr3t" || len(blob) == 0 { + t.Fatalf("password not encrypted at rest: %q", blob) + } +} + +func TestGetAccountNotFound(t *testing.T) { + s := openTemp(t) + if _, err := s.GetAccount("nope"); !errors.Is(err, ErrAccountNotFound) { + t.Fatalf("want ErrAccountNotFound, got %v", err) + } +} + +func TestListAccountsOmitsSecrets(t *testing.T) { + s := openTemp(t) + _, _ = s.AddAccount(sampleAccount()) + list, err := s.ListAccounts() + if err != nil || len(list) != 1 { + t.Fatalf("list: %v len=%d", err, len(list)) + } + if list[0].Password != "" { + t.Fatal("ListAccounts must not return secrets") + } +}