diff --git a/aes/cipher.go b/aes/cipher.go index 291f2fedf..e1009f2a5 100644 --- a/aes/cipher.go +++ b/aes/cipher.go @@ -11,6 +11,7 @@ import ( "fmt" "regexp" "strconv" + "time" "github.com/getsops/sops/v3" "github.com/getsops/sops/v3/logging" @@ -110,6 +111,10 @@ func (c Cipher) Decrypt(ciphertext string, key []byte, additionalData string) (p plaintext = decryptedBytes case "bool": plaintext, err = strconv.ParseBool(decryptedValue) + case "time": + var value time.Time + err = value.UnmarshalText(decryptedBytes) + plaintext = value case "comment": plaintext = sops.Comment{Value: decryptedValue} default: @@ -176,6 +181,12 @@ func (c Cipher) Encrypt(plaintext interface{}, key []byte, additionalData string } else { plainBytes = []byte("False") } + case time.Time: + encryptedType = "time" + plainBytes, err = value.MarshalText() + if err != nil { + return "", fmt.Errorf("Error marshaling timestamp %q: %w", value, err) + } case sops.Comment: encryptedType = "comment" plainBytes = []byte(value.Value) diff --git a/aes/cipher_test.go b/aes/cipher_test.go index 4d53510aa..2c2421faf 100644 --- a/aes/cipher_test.go +++ b/aes/cipher_test.go @@ -1,13 +1,16 @@ package aes import ( + "bytes" "crypto/rand" + "reflect" "strings" "testing" "testing/quick" + "time" - "github.com/stretchr/testify/assert" "github.com/getsops/sops/v3" + "github.com/stretchr/testify/assert" ) func TestDecrypt(t *testing.T) { @@ -108,6 +111,36 @@ func TestRoundtripBool(t *testing.T) { } } +func TestRoundtripTime(t *testing.T) { + key := []byte(strings.Repeat("f", 32)) + parsedTime, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00") + assert.Nil(t, err) + loc := time.FixedZone("", 12300) // offset must be divisible by 60, otherwise won't survive a round-trip + values := []time.Time{ + time.UnixMilli(0).In(time.UTC), + time.UnixMilli(123456).In(time.UTC), + time.UnixMilli(123456).In(loc), + time.UnixMilli(123456789).In(time.UTC), + time.UnixMilli(123456789).In(loc), + time.UnixMilli(1234567890).In(time.UTC), + time.UnixMilli(1234567890).In(loc), + parsedTime, + } + for _, value := range values { + s, err := NewCipher().Encrypt(value, key, "foo") + assert.Nil(t, err) + if err != nil { + continue + } + d, err := NewCipher().Decrypt(s, key, "foo") + assert.Nil(t, err) + if err != nil { + continue + } + assert.Equal(t, value, d) + } +} + func TestEncryptEmptyComment(t *testing.T) { key := []byte(strings.Repeat("f", 32)) s, err := NewCipher().Encrypt(sops.Comment{}, key, "") @@ -121,3 +154,61 @@ func TestDecryptEmptyValue(t *testing.T) { assert.Nil(t, err) assert.Equal(t, "", s) } + +// This test would belong more in sops_test.go, but from there we cannot access +// the aes package to get a cipher which can actually handle time.Time objects. +func TestTimestamps(t *testing.T) { + unixTime := time.UnixMilli(123456789).In(time.UTC) + parsedTime, err := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00") + assert.Nil(t, err) + branches := sops.TreeBranches{ + sops.TreeBranch{ + sops.TreeItem{ + Key: "foo", + Value: unixTime, + }, + sops.TreeItem{ + Key: "bar", + Value: sops.TreeBranch{ + sops.TreeItem{ + Key: "foo", + Value: parsedTime, + }, + }, + }, + }, + } + tree := sops.Tree{Branches: branches, Metadata: sops.Metadata{UnencryptedSuffix: "_unencrypted"}} + expected := sops.TreeBranch{ + sops.TreeItem{ + Key: "foo", + Value: unixTime, + }, + sops.TreeItem{ + Key: "bar", + Value: sops.TreeBranch{ + sops.TreeItem{ + Key: "foo", + Value: parsedTime, + }, + }, + }, + } + cipher := NewCipher() + _, err = tree.Encrypt(bytes.Repeat([]byte("f"), 32), cipher) + if err != nil { + t.Errorf("Encrypting the tree failed: %s", err) + } + if reflect.DeepEqual(tree.Branches[0], expected) { + t.Errorf("Trees do match: \ngot \t\t%+v,\n not expected \t\t%+v", tree.Branches[0], expected) + } + _, err = tree.Decrypt(bytes.Repeat([]byte("f"), 32), cipher) + if err != nil { + t.Errorf("Decrypting the tree failed: %s", err) + } + assert.Equal(t, tree.Branches[0][0].Value, unixTime) + assert.Equal(t, tree.Branches[0], expected) + if !reflect.DeepEqual(tree.Branches[0], expected) { + t.Errorf("Trees don't match: \ngot\t\t\t%+v,\nexpected\t\t%+v", tree.Branches[0], expected) + } +} diff --git a/functional-tests/src/lib.rs b/functional-tests/src/lib.rs index 7d1793bb9..745fd640b 100644 --- a/functional-tests/src/lib.rs +++ b/functional-tests/src/lib.rs @@ -659,6 +659,50 @@ b: ba"# } } + #[test] + fn test_yaml_time() { + let file_path = prepare_temp_file( + "test_time.yaml", + r#"a: 2024-01-01 +b: 2006-01-02T15:04:05+07:06"# + .as_bytes(), + ); + assert!( + Command::new(SOPS_BINARY_PATH) + .arg("encrypt") + .arg("-i") + .arg(file_path.clone()) + .output() + .expect("Error running sops") + .status + .success(), + "sops didn't exit successfully" + ); + let output = Command::new(SOPS_BINARY_PATH) + .arg("decrypt") + .arg("-i") + .arg(file_path.clone()) + .output() + .expect("Error running sops"); + println!( + "stdout: {}, stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.status.success(), "sops didn't exit successfully"); + let mut s = String::new(); + File::open(file_path) + .unwrap() + .read_to_string(&mut s) + .unwrap(); + assert_eq!( + s, + r#"a: 2024-01-01T00:00:00Z +b: 2006-01-02T15:04:05+07:06 +"# + ); + } + #[test] fn unset_json_file() { // Test removal of tree branch diff --git a/sops.go b/sops.go index 01c103d45..39615c20d 100644 --- a/sops.go +++ b/sops.go @@ -347,6 +347,8 @@ func (branch TreeBranch) walkValue(in interface{}, path []string, commentsStack return onLeaves(in, path, commentsStack) case float64: return onLeaves(in, path, commentsStack) + case time.Time: + return onLeaves(in, path, commentsStack) case Comment: return onLeaves(in, path, commentsStack) case TreeBranch: @@ -968,6 +970,8 @@ func ToBytes(in interface{}) ([]byte, error) { return boolB, nil case []byte: return in, nil + case time.Time: + return in.MarshalText() case Comment: return ToBytes(in.Value) default: