@@ -23,10 +23,11 @@ type AppStatus struct {
2323}
2424
2525type 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
3233type 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