@@ -25,6 +25,8 @@ import (
2525 "errors"
2626 "io/fs"
2727 "os"
28+ "strconv"
29+ "strings"
2830 "testing"
2931
3032 "filippo.io/age"
@@ -799,3 +801,204 @@ func TestPOSIXAge(t *testing.T) {
799801 assert .ErrorIs (t , err , decryptError )
800802 })
801803}
804+
805+ // scryptStanzaWorkFactor extracts the logN work factor recorded in the scrypt
806+ // stanza of an age file header. The header is ASCII; the scrypt stanza has the
807+ // form "-> scrypt <base64 salt> <logN>".
808+ func scryptStanzaWorkFactor (t * testing.T , ageFile []byte ) int {
809+ t .Helper ()
810+ for line := range strings .SplitSeq (string (ageFile ), "\n " ) {
811+ if ! strings .HasPrefix (line , "-> scrypt " ) {
812+ continue
813+ }
814+ fields := strings .Fields (line )
815+ logN , err := strconv .Atoi (fields [len (fields )- 1 ])
816+ require .NoError (t , err )
817+ return logN
818+ }
819+ t .Fatal ("no scrypt stanza found in age header" )
820+ return 0
821+ }
822+
823+ // newTempRoot opens an os.Root over a per-test temporary directory and closes
824+ // it on cleanup.
825+ func newTempRoot (t * testing.T ) * os.Root {
826+ t .Helper ()
827+ root , err := os .OpenRoot (t .TempDir ())
828+ require .NoError (t , err )
829+ t .Cleanup (func () {
830+ assert .NoError (t , root .Close ())
831+ })
832+ return root
833+ }
834+
835+ // newPasswordStore builds a posixage store over root that encrypts and decrypts
836+ // with a single password, plus any extra options.
837+ func newPasswordStore (t * testing.T , root * os.Root , password string , opts ... Options ) store.Store {
838+ t .Helper ()
839+ base := []Options {
840+ WithLogger (& testLogger {t }),
841+ WithEncryptionCallbackFunc [EncryptionPassword ](func (_ context.Context ) ([]byte , error ) {
842+ return []byte (password ), nil
843+ }),
844+ WithDecryptionCallbackFunc [DecryptionPassword ](func (_ context.Context ) ([]byte , error ) {
845+ return []byte (password ), nil
846+ }),
847+ }
848+ s , err := New (root ,
849+ func (_ context.Context , _ store.ID ) * mocks.MockCredential {
850+ return & mocks.MockCredential {}
851+ },
852+ append (base , opts ... )... ,
853+ )
854+ require .NoError (t , err )
855+ return s
856+ }
857+
858+ // readPassSecret reads the raw password-encrypted age file for id.
859+ func readPassSecret (t * testing.T , root * os.Root , id store.ID ) []byte {
860+ t .Helper ()
861+ encodedID := base64 .StdEncoding .EncodeToString ([]byte (id .String ()))
862+ secretRoot , err := root .OpenRoot (encodedID )
863+ require .NoError (t , err )
864+ t .Cleanup (func () {
865+ assert .NoError (t , secretRoot .Close ())
866+ })
867+ data , err := secretRoot .ReadFile (secretfile .SecretFileName + "pass" )
868+ require .NoError (t , err )
869+ return data
870+ }
871+
872+ func TestScryptWorkFactor (t * testing.T ) {
873+ // Use a low work factor so the KDF stays fast in tests; the default (18) is
874+ // tuned to take ~1s per operation.
875+ const workFactor = 10
876+
877+ t .Run ("records the configured work factor in the header" , func (t * testing.T ) {
878+ password := uuid .NewString ()
879+ root := newTempRoot (t )
880+ s := newPasswordStore (t , root , password , WithScryptWorkFactor (workFactor ))
881+
882+ secret := & mocks.MockCredential {Username : uuid .NewString (), Password : uuid .NewString ()}
883+ id := secrets .MustParseID ("test/something/" + uuid .NewString ())
884+ require .NoError (t , s .Save (t .Context (), id , secret ))
885+
886+ encryptedFile := readPassSecret (t , root , id )
887+ assert .Equal (t , workFactor , scryptStanzaWorkFactor (t , encryptedFile ))
888+
889+ // Round-trips with the same store.
890+ got , err := s .Get (t .Context (), id )
891+ require .NoError (t , err )
892+ assert .EqualValues (t , secret , got )
893+ })
894+
895+ t .Run ("defaults to the age work factor when unset" , func (t * testing.T ) {
896+ password := uuid .NewString ()
897+ root := newTempRoot (t )
898+ s := newPasswordStore (t , root , password )
899+
900+ secret := & mocks.MockCredential {Username : uuid .NewString (), Password : uuid .NewString ()}
901+ id := secrets .MustParseID ("test/something/" + uuid .NewString ())
902+ require .NoError (t , s .Save (t .Context (), id , secret ))
903+
904+ encryptedFile := readPassSecret (t , root , id )
905+ // age's NewScryptRecipient default is logN=18.
906+ assert .Equal (t , 18 , scryptStanzaWorkFactor (t , encryptedFile ))
907+ })
908+
909+ t .Run ("work factor is self-describing across stores" , func (t * testing.T ) {
910+ // A file written with a custom work factor must decrypt on a store that
911+ // has no work-factor option configured: the factor is read from the
912+ // header, not from store configuration.
913+ password := uuid .NewString ()
914+ root := newTempRoot (t )
915+ writer := newPasswordStore (t , root , password , WithScryptWorkFactor (workFactor ))
916+
917+ secret := & mocks.MockCredential {Username : uuid .NewString (), Password : uuid .NewString ()}
918+ id := secrets .MustParseID ("test/something/" + uuid .NewString ())
919+ require .NoError (t , writer .Save (t .Context (), id , secret ))
920+
921+ // A separate store with no work-factor option configured.
922+ reader := newPasswordStore (t , root , password )
923+
924+ got , err := reader .Get (t .Context (), id )
925+ require .NoError (t , err )
926+ assert .EqualValues (t , secret , got )
927+ })
928+
929+ t .Run ("rejects out-of-range work factors" , func (t * testing.T ) {
930+ root := newTempRoot (t )
931+
932+ for _ , logN := range []int {0 , - 1 , secretfile .MaxScryptWorkFactor + 1 } {
933+ _ , err := New (root ,
934+ func (_ context.Context , _ store.ID ) * mocks.MockCredential {
935+ return & mocks.MockCredential {}
936+ },
937+ WithEncryptionCallbackFunc [EncryptionPassword ](func (_ context.Context ) ([]byte , error ) {
938+ return []byte ("a-password" ), nil
939+ }),
940+ WithDecryptionCallbackFunc [DecryptionPassword ](func (_ context.Context ) ([]byte , error ) {
941+ return []byte ("a-password" ), nil
942+ }),
943+ WithScryptWorkFactor (logN ),
944+ )
945+ assert .Error (t , err , "logN=%d should be rejected" , logN )
946+ }
947+ })
948+ }
949+
950+ // TestScryptWorkFactorMigration documents how an existing secret's scrypt work
951+ // factor is migrated: there is no transparent on-read migration, but because
952+ // Save/Upsert re-encrypts the whole secret, re-writing a secret with a store
953+ // configured for a different work factor migrates the stored file to that
954+ // factor while preserving the plaintext. The factor moves in both directions.
955+ func TestScryptWorkFactorMigration (t * testing.T ) {
956+ password := uuid .NewString ()
957+ root := newTempRoot (t )
958+
959+ secret := & mocks.MockCredential {
960+ Username : uuid .NewString (),
961+ Password : uuid .NewString (),
962+ Attributes : map [string ]string {
963+ "val1" : uuid .NewString (),
964+ },
965+ }
966+ id := secrets .MustParseID ("test/something/" + uuid .NewString ())
967+
968+ // Seed the secret with the age default (logN=18), simulating data written
969+ // by a build that never configured a work factor.
970+ initial := newPasswordStore (t , root , password )
971+ require .NoError (t , initial .Save (t .Context (), id , secret ))
972+ require .Equal (t , 18 , scryptStanzaWorkFactor (t , readPassSecret (t , root , id )))
973+
974+ // Each step re-writes the same secret with a store configured for a
975+ // different work factor: default -> lower -> higher.
976+ steps := []struct {
977+ name string
978+ workFactor int
979+ }{
980+ {"migrate to a lower work factor" , 8 },
981+ {"migrate to a higher work factor" , 14 },
982+ }
983+
984+ for _ , step := range steps {
985+ t .Run (step .name , func (t * testing.T ) {
986+ s := newPasswordStore (t , root , password , WithScryptWorkFactor (step .workFactor ))
987+
988+ // The old file (written with the previous factor) must still decrypt
989+ // on this store, since the factor is read from the file header.
990+ got , err := s .Get (t .Context (), id )
991+ require .NoError (t , err )
992+ assert .EqualValues (t , secret , got )
993+
994+ // Re-writing migrates the stored file to the new work factor.
995+ require .NoError (t , s .Upsert (t .Context (), id , secret ))
996+ assert .Equal (t , step .workFactor , scryptStanzaWorkFactor (t , readPassSecret (t , root , id )))
997+
998+ // The migrated file still decrypts to the same plaintext.
999+ got , err = s .Get (t .Context (), id )
1000+ require .NoError (t , err )
1001+ assert .EqualValues (t , secret , got )
1002+ })
1003+ }
1004+ }
0 commit comments