diff --git a/frontend/wailsjs/go/auth/Service.d.ts b/frontend/wailsjs/go/auth/Service.d.ts index 135e4cb..9507acd 100755 --- a/frontend/wailsjs/go/auth/Service.d.ts +++ b/frontend/wailsjs/go/auth/Service.d.ts @@ -31,3 +31,5 @@ export function SetMasterKeyStorage(arg1:string):Promise; export function Startup(arg1:context.Context):Promise; export function TouchSession():Promise; + +export function WithMasterKey(arg1:any):Promise; diff --git a/frontend/wailsjs/go/auth/Service.js b/frontend/wailsjs/go/auth/Service.js index dcf952b..d4f984a 100755 --- a/frontend/wailsjs/go/auth/Service.js +++ b/frontend/wailsjs/go/auth/Service.js @@ -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); +} diff --git a/go.mod b/go.mod index ac9e868..6818d38 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/go.sum b/go.sum index 8b4b196..c021156 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/features/auth/repository.go b/internal/features/auth/repository.go index 3843112..5b243e1 100644 --- a/internal/features/auth/repository.go +++ b/internal/features/auth/repository.go @@ -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 { @@ -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. @@ -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 +} diff --git a/internal/features/auth/service.go b/internal/features/auth/service.go index 226746a..d2c6e0d 100644 --- a/internal/features/auth/service.go +++ b/internal/features/auth/service.go @@ -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" ) @@ -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 @@ -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. @@ -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 @@ -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() @@ -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. diff --git a/internal/features/settings/service.go b/internal/features/settings/service.go index dee9c85..819aed3 100644 --- a/internal/features/settings/service.go +++ b/internal/features/settings/service.go @@ -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 @@ -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) } @@ -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) } diff --git a/internal/features/upload/processor.go b/internal/features/upload/processor.go index 4e7ad2b..9faa21e 100644 --- a/internal/features/upload/processor.go +++ b/internal/features/upload/processor.go @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/internal/features/upload/service.go b/internal/features/upload/service.go index 815f497..701d397 100644 --- a/internal/features/upload/service.go +++ b/internal/features/upload/service.go @@ -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 diff --git a/internal/shared/crypto/encryption.go b/internal/shared/crypto/encryption.go index bc94b72..b899cec 100644 --- a/internal/shared/crypto/encryption.go +++ b/internal/shared/crypto/encryption.go @@ -498,3 +498,58 @@ func deriveChunkNonce(baseNonce []byte, counter uint64) []byte { return nonce } + +// SplitKey splits a 32-byte master key into two equal-length components (pieceA +// and pieceB) using XOR-splitting (A ⊕ B = Key). Piece B is filled with random +// cryptographically secure bytes, and piece A is computed as key ⊕ pieceB. +// Neither piece alone reveals any information about the original key. +func SplitKey(key []byte) ([]byte, []byte, error) { + if len(key) != KeySize { + return nil, nil, fmt.Errorf("invalid key length: expected %d bytes, got %d", KeySize, len(key)) + } + + pieceB := make([]byte, KeySize) + if _, err := io.ReadFull(rand.Reader, pieceB); err != nil { + return nil, nil, fmt.Errorf("generate random piece: %w", err) + } + + pieceA := make([]byte, KeySize) + for i := 0; i < KeySize; i++ { + pieceA[i] = key[i] ^ pieceB[i] + } + + return pieceA, pieceB, nil +} + +// CombineKey reconstructs the original 32-byte master key from XOR components +// pieceA and pieceB (A ⊕ B = Key). +func CombineKey(pieceA, pieceB []byte) ([]byte, error) { + if len(pieceA) != KeySize || len(pieceB) != KeySize { + return nil, fmt.Errorf( + "invalid piece length: expected %d bytes each, got %d and %d", + KeySize, len(pieceA), len(pieceB), + ) + } + + key := make([]byte, KeySize) + for i := 0; i < KeySize; i++ { + key[i] = pieceA[i] ^ pieceB[i] + } + return key, nil +} + +// CombineKeyToBuffer reconstructs the original 32-byte master key into the +// pre-allocated dst buffer (such as a memguard LockedBuffer). +func CombineKeyToBuffer(pieceA, pieceB, dst []byte) error { + if len(pieceA) != KeySize || len(pieceB) != KeySize || len(dst) != KeySize { + return fmt.Errorf( + "invalid slice length: expected %d bytes each, got pieceA=%d, pieceB=%d, dst=%d", + KeySize, len(pieceA), len(pieceB), len(dst), + ) + } + + for i := 0; i < KeySize; i++ { + dst[i] = pieceA[i] ^ pieceB[i] + } + return nil +}