Skip to content

Commit 2a67529

Browse files
committed
upgrade ux and implement lazy decryption mode
1 parent f31a81a commit 2a67529

19 files changed

Lines changed: 1118 additions & 44 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ encryption, not just a hidden folder.
1717
- **Multiple keys per vault**: register as many security keys as you want, so losing one doesn't mean losing access
1818
- **Post-login 2FA**: opt-in kiosk lock screen that requires security key authentication after OS sign-in
1919
- **File and folder encryption**: encrypt individual files or entire folders, all in place
20+
- **Lazy decryption**: choose per vault whether to decrypt eagerly on unlock, on demand, or on demand with re-authentication (PIN + touch)
2021
- **Protect sudo and su**: prevent privilege escalation without security key authentication
2122
- **Easy backup**: export your vault configuration to recover access and prevent lockout
2223

@@ -26,6 +27,7 @@ encryption, not just a hidden folder.
2627
2. Add folders or individual files to protect
2728
3. On lock (app close, sleep, or logout), everything is encrypted in place
2829
4. On unlock, enter your PIN and touch the key to decrypt
30+
5. Optionally set vaults to lazy or strict mode — lazy vaults stay encrypted until you decrypt them on demand, strict vaults require a fresh PIN + touch each time
2931

3032
## Security
3133

@@ -35,6 +37,7 @@ encryption, not just a hidden folder.
3537
- **Auto-lock**: Vaults lock on sleep, logout, app quit, and SIGTERM/SIGINT.
3638
- **Metadata protection**: Manifests are encrypted. Original filenames and directory structure are not visible when locked. Individual file vaults are stored in opaque directories with hashed names.
3739
- **Crash safety**: A write-ahead journal ensures files are never lost during lock/unlock, even on power failure.
40+
- **Per-vault strict keys**: Strict mode vaults use a unique encryption key derived per vault path. The key only exists in memory during an active re-authentication and is zeroed immediately after.
3841
- **No plaintext key material touches the filesystem.**
3942

4043
## Supported Keys

desktop/app.go

Lines changed: 324 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ type AppStatus struct {
2323
}
2424

2525
type VaultStatus struct {
26-
Label string `json:"label"`
27-
Path string `json:"path"`
28-
Type string `json:"type,omitempty"`
29-
Locked bool `json:"locked"`
26+
Label string `json:"label"`
27+
Path string `json:"path"`
28+
Type string `json:"type,omitempty"`
29+
Locked bool `json:"locked"`
30+
DecryptMode string `json:"decrypt_mode"`
3031
}
3132

3233
type KeyInfo struct {
@@ -482,8 +483,11 @@ func (a *App) Unlock(pin string) error {
482483
return fmt.Errorf("loading config: %w", err)
483484
}
484485

485-
// Unlock all vaults
486+
// Unlock all eager vaults
486487
for _, v := range cfg.Vaults {
488+
if sc.VaultDecryptMode(v.Path) != monban.DecryptEager {
489+
continue
490+
}
487491
if err := monban.UnlockVaultEntry(encKey, v); err != nil {
488492
return err
489493
}
@@ -503,8 +507,28 @@ func (a *App) Lock() error {
503507
a.mu.Lock()
504508
defer a.mu.Unlock()
505509

506-
if a.config != nil && a.encKey != nil {
507-
for _, v := range a.config.Vaults {
510+
if a.config == nil || a.secureCfg == nil {
511+
return fmt.Errorf("config not found")
512+
}
513+
514+
hmacSalt, err := monban.DecodeB64(a.secureCfg.HmacSalt)
515+
if err != nil {
516+
return fmt.Errorf("decoding hmac salt: %w", err)
517+
}
518+
519+
for _, v := range a.config.Vaults {
520+
mode := a.secureCfg.VaultDecryptMode(v.Path)
521+
if mode == monban.DecryptLazyStrict {
522+
lazyKey, err := monban.DeriveLazyStrictKey(a.masterSecret, hmacSalt, v.Path)
523+
if err != nil {
524+
return fmt.Errorf("deriving lazy strict key: %w", err)
525+
}
526+
if err := monban.LockVaultEntry(lazyKey, v); err != nil {
527+
monban.ZeroBytes(lazyKey)
528+
return err
529+
}
530+
monban.ZeroBytes(lazyKey)
531+
} else {
508532
if err := monban.LockVaultEntry(a.encKey, v); err != nil {
509533
return err
510534
}
@@ -536,18 +560,25 @@ func (a *App) GetStatus() AppStatus {
536560
return status
537561
}
538562

563+
sc, _ := monban.LoadSecureConfig()
564+
539565
for _, v := range cfg.Vaults {
540566
locked := false
541567
if v.IsFile() {
542568
locked = monban.IsFileLocked(v.Path)
543569
} else {
544570
locked = monban.IsLocked(v.Path)
545571
}
572+
decryptMode := "eager"
573+
if sc != nil {
574+
decryptMode = string(sc.VaultDecryptMode(v.Path))
575+
}
546576
status.Vaults = append(status.Vaults, VaultStatus{
547-
Label: v.Label,
548-
Path: v.Path,
549-
Type: v.Type,
550-
Locked: locked,
577+
Label: v.Label,
578+
Path: v.Path,
579+
Type: v.Type,
580+
Locked: locked,
581+
DecryptMode: decryptMode,
551582
})
552583
}
553584

@@ -808,3 +839,285 @@ func (a *App) AddFile(path string) error {
808839
a.config = cfg
809840
return nil
810841
}
842+
843+
func (a *App) DecryptLazyVault(path string, pin string) error {
844+
a.mu.Lock()
845+
defer a.mu.Unlock()
846+
847+
if a.config == nil {
848+
return fmt.Errorf("no config found")
849+
}
850+
851+
absPath, err := filepath.Abs(path)
852+
if err != nil {
853+
return fmt.Errorf("resolving path: %w", err)
854+
}
855+
856+
idx := monban.FindVaultIndex(a.config.Vaults, absPath)
857+
if idx == -1 {
858+
return fmt.Errorf("not found: %s", absPath)
859+
}
860+
861+
v := a.config.Vaults[idx]
862+
decMode := a.secureCfg.VaultDecryptMode(absPath)
863+
864+
if decMode == monban.DecryptEager || decMode == monban.DecryptLazy {
865+
if err := monban.UnlockVaultEntry(a.encKey, v); err != nil {
866+
return err
867+
}
868+
return nil
869+
}
870+
871+
// lazy_strict: re-authenticate with FIDO2 to derive per-vault key
872+
masterSecret, err := a.fidoReauth(pin)
873+
if err != nil {
874+
return fmt.Errorf("FIDO2 re-auth failed: %w", err)
875+
}
876+
defer monban.ZeroBytes(masterSecret)
877+
878+
hmacSalt, err := monban.DecodeB64(a.secureCfg.HmacSalt)
879+
if err != nil {
880+
return fmt.Errorf("decoding hmac salt: %w", err)
881+
}
882+
883+
lazyStrictKey, err := monban.DeriveLazyStrictKey(masterSecret, hmacSalt, absPath)
884+
if err != nil {
885+
return fmt.Errorf("deriving lazy strict key: %w", err)
886+
}
887+
defer monban.ZeroBytes(lazyStrictKey)
888+
889+
if err := monban.UnlockVaultEntry(lazyStrictKey, v); err != nil {
890+
return err
891+
}
892+
893+
return nil
894+
}
895+
896+
// LockVault re-encrypts a single vault on demand.
897+
func (a *App) LockVault(path string) error {
898+
a.mu.Lock()
899+
defer a.mu.Unlock()
900+
901+
if a.locked {
902+
return fmt.Errorf("app is locked")
903+
}
904+
905+
if a.config == nil || a.secureCfg == nil {
906+
return fmt.Errorf("no config found")
907+
}
908+
909+
absPath, err := filepath.Abs(path)
910+
if err != nil {
911+
return fmt.Errorf("resolving path: %w", err)
912+
}
913+
914+
idx := monban.FindVaultIndex(a.config.Vaults, absPath)
915+
if idx == -1 {
916+
return fmt.Errorf("not found: %s", absPath)
917+
}
918+
919+
v := a.config.Vaults[idx]
920+
mode := a.secureCfg.VaultDecryptMode(absPath)
921+
922+
if mode == monban.DecryptLazyStrict {
923+
hmacSalt, err := monban.DecodeB64(a.secureCfg.HmacSalt)
924+
if err != nil {
925+
return fmt.Errorf("decoding hmac salt: %w", err)
926+
}
927+
lazyKey, err := monban.DeriveLazyStrictKey(a.masterSecret, hmacSalt, absPath)
928+
if err != nil {
929+
return fmt.Errorf("deriving lazy strict key: %w", err)
930+
}
931+
if err := monban.LockVaultEntry(lazyKey, v); err != nil {
932+
monban.ZeroBytes(lazyKey)
933+
return err
934+
}
935+
monban.ZeroBytes(lazyKey)
936+
} else {
937+
if err := monban.LockVaultEntry(a.encKey, v); err != nil {
938+
return err
939+
}
940+
}
941+
942+
return nil
943+
}
944+
945+
// fidoReauth performs FIDO2 re-authentication and returns a fresh master secret.
946+
// The caller is responsible for zeroing the returned secret.
947+
// Must be called with a.mu held.
948+
func (a *App) fidoReauth(pin string) ([]byte, error) {
949+
sc, err := monban.LoadSecureConfig()
950+
if err != nil {
951+
return nil, fmt.Errorf("loading secure config: %w", err)
952+
}
953+
954+
if len(sc.Credentials) == 0 {
955+
return nil, fmt.Errorf("no credentials registered")
956+
}
957+
958+
hmacSalt, err := monban.DecodeB64(sc.HmacSalt)
959+
if err != nil {
960+
return nil, fmt.Errorf("decoding hmac salt: %w", err)
961+
}
962+
963+
credIDs := make([][]byte, len(sc.Credentials))
964+
for i, c := range sc.Credentials {
965+
id, err := monban.DecodeB64(c.CredentialID)
966+
if err != nil {
967+
return nil, fmt.Errorf("decoding credential ID: %w", err)
968+
}
969+
credIDs[i] = id
970+
}
971+
972+
assertion, err := monban.Assert(pin, credIDs, hmacSalt)
973+
if err != nil {
974+
return nil, fmt.Errorf("FIDO2 assertion failed: %w", err)
975+
}
976+
977+
if len(assertion.HMACSecret) == 0 {
978+
return nil, fmt.Errorf("security key did not return hmac-secret")
979+
}
980+
981+
wrappingKey, err := monban.DeriveWrappingKey(assertion.HMACSecret, hmacSalt)
982+
defer monban.ZeroBytes(assertion.HMACSecret, wrappingKey)
983+
if err != nil {
984+
return nil, err
985+
}
986+
987+
var masterSecret []byte
988+
var matchedCred *monban.CredentialEntry
989+
for i := range sc.Credentials {
990+
wrapped, err := monban.DecodeB64(sc.Credentials[i].WrappedKey)
991+
if err != nil {
992+
continue
993+
}
994+
secret, err := monban.UnwrapKey(wrappingKey, wrapped)
995+
if err != nil {
996+
continue
997+
}
998+
masterSecret = secret
999+
matchedCred = &sc.Credentials[i]
1000+
break
1001+
}
1002+
1003+
if masterSecret == nil {
1004+
return nil, fmt.Errorf("could not unwrap master secret — no matching credential found")
1005+
}
1006+
1007+
pubX, err := monban.DecodeB64(matchedCred.PublicKeyX)
1008+
if err != nil {
1009+
monban.ZeroBytes(masterSecret)
1010+
return nil, fmt.Errorf("decoding public key X: %w", err)
1011+
}
1012+
pubY, err := monban.DecodeB64(matchedCred.PublicKeyY)
1013+
if err != nil {
1014+
monban.ZeroBytes(masterSecret)
1015+
return nil, fmt.Errorf("decoding public key Y: %w", err)
1016+
}
1017+
cdh := sha256.Sum256(hmacSalt)
1018+
if err := monban.VerifyAssertion(pubX, pubY, cdh[:], assertion.AuthDataCBOR, assertion.Sig); err != nil {
1019+
monban.ZeroBytes(masterSecret)
1020+
return nil, fmt.Errorf("assertion verification failed: %w", err)
1021+
}
1022+
1023+
return masterSecret, nil
1024+
}
1025+
1026+
// UpdateVaultMode changes the decrypt mode for a vault.
1027+
func (a *App) UpdateVaultMode(path string, mode string, pin string) error {
1028+
a.mu.Lock()
1029+
defer a.mu.Unlock()
1030+
1031+
if a.locked {
1032+
return fmt.Errorf("must be unlocked")
1033+
}
1034+
1035+
if a.config == nil || a.secureCfg == nil {
1036+
return fmt.Errorf("no config found")
1037+
}
1038+
1039+
absPath, err := filepath.Abs(path)
1040+
if err != nil {
1041+
return fmt.Errorf("resolving path: %w", err)
1042+
}
1043+
1044+
idx := monban.FindVaultIndex(a.config.Vaults, absPath)
1045+
if idx == -1 {
1046+
return fmt.Errorf("not found: %s", absPath)
1047+
}
1048+
1049+
v := a.config.Vaults[idx]
1050+
newMode := monban.DecryptMode(mode)
1051+
oldMode := a.secureCfg.VaultDecryptMode(absPath)
1052+
1053+
if oldMode == newMode {
1054+
return nil
1055+
}
1056+
1057+
hmacSalt, err := monban.DecodeB64(a.secureCfg.HmacSalt)
1058+
if err != nil {
1059+
return fmt.Errorf("decoding hmac salt: %w", err)
1060+
}
1061+
1062+
switch {
1063+
case oldMode != monban.DecryptLazyStrict && newMode != monban.DecryptLazyStrict:
1064+
// eager <-> lazy: no re-encryption needed, just update flag
1065+
1066+
case oldMode != monban.DecryptLazyStrict && newMode == monban.DecryptLazyStrict:
1067+
// eager/lazy -> lazy_strict: decrypt with encKey if locked, then re-encrypt with lazyStrictKey
1068+
if err := monban.UnlockVaultEntry(a.encKey, v); err != nil {
1069+
return fmt.Errorf("decrypting vault for mode change: %w", err)
1070+
}
1071+
lazyStrictKey, err := monban.DeriveLazyStrictKey(a.masterSecret, hmacSalt, absPath)
1072+
if err != nil {
1073+
return fmt.Errorf("deriving lazy strict key: %w", err)
1074+
}
1075+
if err := monban.LockVaultEntry(lazyStrictKey, v); err != nil {
1076+
monban.ZeroBytes(lazyStrictKey)
1077+
return fmt.Errorf("re-encrypting vault with lazy strict key: %w", err)
1078+
}
1079+
monban.ZeroBytes(lazyStrictKey)
1080+
1081+
case oldMode == monban.DecryptLazyStrict && newMode != monban.DecryptLazyStrict:
1082+
// lazy_strict -> eager/lazy: need FIDO2 re-auth to get lazyStrictKey
1083+
masterSecret, err := a.fidoReauth(pin)
1084+
if err != nil {
1085+
return fmt.Errorf("FIDO2 re-auth failed: %w", err)
1086+
}
1087+
1088+
lazyStrictKey, err := monban.DeriveLazyStrictKey(masterSecret, hmacSalt, absPath)
1089+
monban.ZeroBytes(masterSecret)
1090+
if err != nil {
1091+
return fmt.Errorf("deriving lazy strict key: %w", err)
1092+
}
1093+
1094+
if err := monban.UnlockVaultEntry(lazyStrictKey, v); err != nil {
1095+
monban.ZeroBytes(lazyStrictKey)
1096+
return fmt.Errorf("decrypting vault from lazy strict: %w", err)
1097+
}
1098+
monban.ZeroBytes(lazyStrictKey)
1099+
1100+
// If new mode is lazy, re-encrypt with encKey
1101+
if newMode == monban.DecryptLazy {
1102+
if err := monban.LockVaultEntry(a.encKey, v); err != nil {
1103+
return fmt.Errorf("re-encrypting vault with enc key: %w", err)
1104+
}
1105+
}
1106+
}
1107+
1108+
// Update the mode in secure config
1109+
if a.secureCfg.VaultDecryptModes == nil {
1110+
a.secureCfg.VaultDecryptModes = make(map[string]monban.DecryptMode)
1111+
}
1112+
if newMode == monban.DecryptEager {
1113+
delete(a.secureCfg.VaultDecryptModes, absPath)
1114+
} else {
1115+
a.secureCfg.VaultDecryptModes[absPath] = newMode
1116+
}
1117+
1118+
if err := monban.SaveSecureConfig(a.secureCfg); err != nil {
1119+
return fmt.Errorf("saving secure config: %w", err)
1120+
}
1121+
1122+
return nil
1123+
}

0 commit comments

Comments
 (0)