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
2 changes: 2 additions & 0 deletions frontend/wailsjs/go/auth/Service.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,5 @@ export function SetMasterKeyStorage(arg1:string):Promise<string>;
export function Startup(arg1:context.Context):Promise<void>;

export function TouchSession():Promise<void>;

export function WithMasterKey(arg1:any):Promise<void>;
4 changes: 4 additions & 0 deletions frontend/wailsjs/go/auth/Service.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,7 @@ export function Startup(arg1) {
export function TouchSession() {
return window['go']['auth']['Service']['TouchSession']();
}

export function WithMasterKey(arg1) {
return window['go']['auth']['Service']['WithMasterKey'](arg1);
}
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.25.0

require (
github.com/alexedwards/argon2id v1.0.0
github.com/awnumar/memguard v0.23.0
github.com/aws/aws-sdk-go-v2 v1.43.4
github.com/aws/aws-sdk-go-v2/credentials v1.19.34
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.0
Expand All @@ -21,6 +22,7 @@ require (

require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
github.com/awnumar/memcall v0.4.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ al.essio.dev/pkg/shellescape v1.5.1 h1:86HrALUujYS/h+GtqoB26SBEdkWfmMI6FubjXlsXy
al.essio.dev/pkg/shellescape v1.5.1/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g=
github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w=
github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A=
github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M=
github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE=
github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.16 h1:aiuaKlDweRC5qExJondpWjOgyzMHpofpwspGXUtwn4c=
Expand Down
55 changes: 55 additions & 0 deletions internal/features/auth/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ type Repository interface {
SaveMasterKeyKeyring(username string, material *Material) error
LoadMasterKeyKeyring(username string) (*Material, error)
DeleteMasterKeyKeyring(username string) error
SaveMasterKeyPieceB(username string, pieceB []byte) error
LoadMasterKeyPieceB(username string) ([]byte, error)
DeleteMasterKeyPieceB(username string) error
}

type repository struct {
Expand Down Expand Up @@ -276,6 +279,21 @@ func (r *repository) DeleteMasterKeyKeyring(username string) error {
return deleteMasterKeyKeyring(username)
}

// SaveMasterKeyPieceB stores Piece B of the XOR-split master key in the OS keyring.
func (r *repository) SaveMasterKeyPieceB(username string, pieceB []byte) error {
return saveMasterKeyPieceB(username, pieceB)
}

// LoadMasterKeyPieceB retrieves Piece B of the XOR-split master key from the OS keyring.
func (r *repository) LoadMasterKeyPieceB(username string) ([]byte, error) {
return loadMasterKeyPieceB(username)
}

// DeleteMasterKeyPieceB removes Piece B of the XOR-split master key from the OS keyring.
func (r *repository) DeleteMasterKeyPieceB(username string) error {
return deleteMasterKeyPieceB(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.
Expand Down Expand Up @@ -461,3 +479,40 @@ func GenerateJunk() (*Material, error) {
RecoveryMasterKey: recoveryMasterKey,
}, nil
}

// ErrPieceBNotFound is returned when no Piece B entry exists in the keyring.
var ErrPieceBNotFound = stderrors.New("master key piece B not found in keyring")

func pieceBKeyringUser(username string) string {
return "pieceb_" + username
}

func saveMasterKeyPieceB(username string, pieceB []byte) error {
encoded := base64.StdEncoding.EncodeToString(pieceB)
if err := keyring.Set("ayo", pieceBKeyringUser(username), encoded); err != nil {
return fmt.Errorf("save master key piece B to keyring: %w", err)
}
return nil
}

func loadMasterKeyPieceB(username string) ([]byte, error) {
encoded, err := keyring.Get("ayo", pieceBKeyringUser(username))
if err != nil {
if keyring.IsNotFound(err) {
return nil, ErrPieceBNotFound
}
return nil, fmt.Errorf("load master key piece B from keyring: %w", err)
}
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("decode master key piece B: %w", err)
}
return decoded, nil
}

func deleteMasterKeyPieceB(username string) error {
if err := keyring.Delete("ayo", pieceBKeyringUser(username)); err != nil {
return fmt.Errorf("delete master key piece B from keyring: %w", err)
}
return nil
}
82 changes: 65 additions & 17 deletions internal/features/auth/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"ayo/internal/shared/dialog"
"ayo/internal/shared/errors"

"github.com/awnumar/memguard"
"github.com/go-playground/validator/v10"
passwordvalidator "github.com/wagslane/go-password-validator"
)
Expand All @@ -37,12 +38,10 @@ const minPasswordEntropy = 70.0
// the running process and is lost on app restart (the frontend re-checks it on
// startup via GetSession).
//
// masterKey is the decrypted key that encrypts all of the user's data. It is
// kept alongside the session so services like settings can encrypt/decrypt
// without re-deriving it from the password, and is exposed to them via the
// MasterKey method. It is deliberately unexported: Session is serialized to the
// frontend via GetSession/RequireSession, and the plaintext master key must
// never reach the webview (Wails only serializes exported fields).
// enclave holds Piece A of the XOR-split master key inside unswappable,
// encrypted RAM managed by memguard. Piece B lives in the OS Keyring.
// Plaintext master keys are reconstructed dynamically via WithMasterKey and
// destroyed immediately after use.
//
// The user's database configuration is deliberately NOT stored here: Session is
// serialized to the frontend via GetSession, and exposing the PostgreSQL
Expand All @@ -51,17 +50,10 @@ const minPasswordEntropy = 70.0
type Session struct {
UserId int64
Username string
masterKey []byte
enclave *memguard.Enclave
lastActiveAt time.Time
}

// MasterKey returns the session's decrypted 32-byte master key. It is how
// other services access the key without it ever being serialized to the
// frontend.
func (s *Session) MasterKey() []byte {
return s.masterKey
}

// Service implements the auth business logic and is the single source of truth
// for the current session. It is bound to the frontend via Wails, so every
// exported method is callable from JavaScript.
Expand Down Expand Up @@ -418,17 +410,33 @@ func (s *Service) Login(input LoginInput) (ok bool, err error) {
defer crypto.Wipe(kek)

// decrypting the master key
masterKey, err := crypto.DecryptMasterKey(kek, material.PasswordMasterKey, material.PasswordNonce)
rawMasterKey, err := crypto.DecryptMasterKey(kek, material.PasswordMasterKey, material.PasswordNonce)
if err != nil {
s.conn.Close()
return false, errors.AsInternalServerError("login: decrypt master key", err)
}
defer crypto.Wipe(rawMasterKey)

pieceA, pieceB, err := crypto.SplitKey(rawMasterKey)
if err != nil {
s.conn.Close()
return false, errors.AsInternalServerError("login: split master key", err)
}
defer crypto.Wipe(pieceA)
defer crypto.Wipe(pieceB)

if err := s.repo.SaveMasterKeyPieceB(user.Username, pieceB); err != nil {
s.conn.Close()
return false, errors.AsInternalServerError("login: save master key piece B", err)
}

enclave := memguard.NewEnclave(pieceA)

// session of the app
s.session = &Session{
UserId: user.ID,
Username: user.Username,
masterKey: masterKey,
enclave: enclave,
lastActiveAt: time.Now(),
}
s.dbConfig = config
Expand Down Expand Up @@ -645,7 +653,11 @@ func (s *Service) checkSessionTimeoutLocked() bool {
}

func (s *Service) logoutLocked() {
s.session = nil
if s.session != nil {
_ = s.repo.DeleteMasterKeyPieceB(s.session.Username)
s.session.enclave = nil
s.session = nil
}
s.dbConfig = dbclient.Config{}
if s.conn != nil {
s.conn.Close()
Expand Down Expand Up @@ -682,6 +694,42 @@ func (s *Service) RequireSession() (*Session, error) {
return s.session, nil
}

// WithMasterKey executes a function with the reconstructed 32-byte master key.
// The master key is temporarily reconstructed in locked, unswappable memory
// using Piece A (from memguard enclave) and Piece B (from OS Keyring), and is
// explicitly destroyed immediately after fn completes.
func (s *Service) WithMasterKey(fn func(masterKey []byte) error) error {
s.mu.Lock()
if s.checkSessionTimeoutLocked() || s.session == nil || s.session.enclave == nil {
s.mu.Unlock()
return errors.ErrUnauthorized
}
session := s.session
repo := s.repo
s.mu.Unlock()

pieceB, err := repo.LoadMasterKeyPieceB(session.Username)
if err != nil {
return errors.AsInternalServerError("with master key: load piece B", err)
}
defer crypto.Wipe(pieceB)

lockedA, err := session.enclave.Open()
if err != nil {
return errors.AsInternalServerError("with master key: open enclave", err)
}
defer lockedA.Destroy()

reconstructedBuf := memguard.NewBuffer(crypto.KeySize)
defer reconstructedBuf.Destroy()

if err := crypto.CombineKeyToBuffer(lockedA.Bytes(), pieceB, reconstructedBuf.Bytes()); err != nil {
return errors.AsInternalServerError("with master key: combine key", err)
}

return fn(reconstructedBuf.Bytes())
}

// CurrentClient returns the signed-in user's active database connection, or
// ErrUnauthorized when signed out. Other DB-backed services use it to resolve
// the active client's dialect/connection when needed.
Expand Down
15 changes: 13 additions & 2 deletions internal/features/settings/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
// SessionProvider is the subset of auth.Service that settings depends on.
type SessionProvider interface {
RequireSession() (*auth.Session, error)
WithMasterKey(fn func(masterKey []byte) error) error
}

// DatabaseConfigProvider exposes the signed-in user's database configuration so
Expand Down Expand Up @@ -105,7 +106,12 @@ func (s *Service) GetSettings() (*Settings, error) {
return defaultSettings, nil
}

decryptedData, err := crypto.DecryptData(session.MasterKey(), data)
var decryptedData []byte
err = s.sessionProvider.WithMasterKey(func(masterKey []byte) error {
var dErr error
decryptedData, dErr = crypto.DecryptData(masterKey, data)
return dErr
})
if err != nil {
return nil, errors.AsInternalServerError("get settings: decrypt", err)
}
Expand Down Expand Up @@ -167,7 +173,12 @@ func (s *Service) UpdateSettings(input UpdateSettingsInput) error {
return errors.AsInternalServerError("update settings: marshal", err)
}

encryptedData, err := crypto.EncryptData(session.MasterKey(), data)
var encryptedData []byte
err = s.sessionProvider.WithMasterKey(func(masterKey []byte) error {
var eErr error
encryptedData, eErr = crypto.EncryptData(masterKey, data)
return eErr
})
if err != nil {
return errors.AsInternalServerError("update settings: encrypt", err)
}
Expand Down
20 changes: 14 additions & 6 deletions internal/features/upload/processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,8 +209,7 @@ func (p *Processor) processUpload(job *queue.Job) {
return
}

session, err := p.sessionProvider.RequireSession()
if err != nil {
if _, err := p.sessionProvider.RequireSession(); err != nil {
// No session means no master key. Keep the job pending rather than
// failing it; it will be picked up on the next resume.
_ = p.queue.UpdateStatusAndProgress(id, queue.StatusPending, 0)
Expand Down Expand Up @@ -255,7 +254,12 @@ func (p *Processor) processUpload(job *queue.Job) {
return
}

encryptedFileKey, keyNonce, err := crypto.WrapDEK(session.MasterKey(), dek)
var encryptedFileKey, keyNonce []byte
err = p.sessionProvider.WithMasterKey(func(masterKey []byte) error {
var wErr error
encryptedFileKey, keyNonce, wErr = crypto.WrapDEK(masterKey, dek)
return wErr
})
if err != nil {
_ = encryptedFile.Close()
_ = p.local.Remove(encryptedPath)
Expand Down Expand Up @@ -426,8 +430,7 @@ func (p *Processor) processDownload(job *queue.Job) {
return
}

session, err := p.sessionProvider.RequireSession()
if err != nil {
if _, err := p.sessionProvider.RequireSession(); err != nil {
// No session means no master key. Keep the job pending rather than
// failing it; it will be picked up on the next resume.
_ = p.queue.UpdateStatusAndProgress(id, queue.StatusPending, 0)
Expand Down Expand Up @@ -542,7 +545,12 @@ func (p *Processor) processDownload(job *queue.Job) {
// at upload time. A corrupted wrapped DEK or key nonce fails the
// authentication check here, aborting the download securely before any
// payload bytes are processed.
dek, err := crypto.UnwrapDEK(session.MasterKey(), upload.encryptedFileKey, upload.keyNonce)
var dek []byte
err = p.sessionProvider.WithMasterKey(func(masterKey []byte) error {
var uErr error
dek, uErr = crypto.UnwrapDEK(masterKey, upload.encryptedFileKey, upload.keyNonce)
return uErr
})
if err != nil {
_ = stagingFile.Close()
_ = p.local.Remove(tempEncryptedPath)
Expand Down
1 change: 1 addition & 0 deletions internal/features/upload/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
// SessionProvider is the subset of auth.Service that upload depends on.
type SessionProvider interface {
RequireSession() (*auth.Session, error)
WithMasterKey(fn func(masterKey []byte) error) error
}

// SettingsProvider is the subset of settings.Service that upload depends on. It
Expand Down
Loading
Loading