From fceadc72c4cc749677307a4b1e759126bb4a81b9 Mon Sep 17 00:00:00 2001 From: Arpit Jain Date: Wed, 3 Jun 2026 07:54:03 +0900 Subject: [PATCH] Format expires with second precision per TUF spec The four role MarshalJSON methods (RootType, SnapshotType, TimestampType, TargetsType) wrote the expires field as a raw time.Time, which encoding/json serializes with RFC3339Nano sub-second precision (for example 2030-01-01T00:00:00.99008Z). The TUF spec requires a whole-second UTC timestamp of the form YYYY-MM-DDTHH:MM:SSZ. Add a small formatExpires helper and use it at all four marshal sites so they stay consistent. Unmarshal is unchanged and still accepts and round-trips sub-second input. Add expires_format_test.go covering all four role types plus a marshal then unmarshal round-trip. Update the existing marshal-output golden assertions in metadata_test.go that encoded the old sub-second output, and regenerate the on-disk signed test fixtures (root, snapshot, targets, timestamp) so their RSA-PSS signatures match the corrected second-precision body. Fixes #596 Signed-off-by: Arpit Jain --- .../repository/metadata/root.json | 4 +- .../repository/metadata/snapshot.json | 4 +- .../repository/metadata/targets.json | 4 +- .../repository/metadata/timestamp.json | 4 +- metadata/expires_format_test.go | 104 ++++++++++++++++++ metadata/marshal.go | 23 +++- metadata/metadata_test.go | 22 ++-- 7 files changed, 145 insertions(+), 20 deletions(-) create mode 100644 metadata/expires_format_test.go diff --git a/internal/testutils/repository_data/repository/metadata/root.json b/internal/testutils/repository_data/repository/metadata/root.json index 6acbb83f..2b017943 100644 --- a/internal/testutils/repository_data/repository/metadata/root.json +++ b/internal/testutils/repository_data/repository/metadata/root.json @@ -2,13 +2,13 @@ "signatures": [ { "keyid": "74b58be26a6ff00ab2eec9b14da29038591a69c212223033f4efdf24489913f2", - "sig": "60d502a798f44577a76a1d7656a60099cd3a995d3d71a0a234aadbd16e38c14611920b8aef9ed78ca4ac0c02277cd72a6fc5ef484a3d66a6c70a61199e462681eb2e667046d4fbc2be1e50cf9fe00fda8fcd6534599eddc91716bf38e7fbbf375524fdb702c74076fd37dcd401d5263783150e851bdba9ef6f9c9a08adf6b289" + "sig": "198d9832af271bd30c1e5ba231a6d2cd357043757c21037ed414c3ee51f11957fb9f525ef743e38f3184d479e026dcc5159a88d75b45c48d265132be092775de64fae540756c03e627b55312cfbe77f5755a044189a9efeaba9cbe2c7d84580f606756881e45b025cebf04a0898ab55aa99081aed7699f75983737d63858171e" } ], "signed": { "_type": "root", "consistent_snapshot": true, - "expires": "2030-08-15T14:30:45.0000001Z", + "expires": "2030-08-15T14:30:45Z", "keys": { "142919f8e933d7045abff3be450070057814da36331d7a22ccade8b35a9e3946": { "keytype": "rsa", diff --git a/internal/testutils/repository_data/repository/metadata/snapshot.json b/internal/testutils/repository_data/repository/metadata/snapshot.json index 24ec1517..aeaf36fb 100644 --- a/internal/testutils/repository_data/repository/metadata/snapshot.json +++ b/internal/testutils/repository_data/repository/metadata/snapshot.json @@ -2,12 +2,12 @@ "signatures": [ { "keyid": "8a14f637b21578cc292a67899df0e46cc160d7fd56e9beae898adb666f4fd9d6", - "sig": "1fdc41488f58482d8af1dad681b9076d54c61b3f5e11bd6cf3c8102b2863471f82c0be2cccd978a9bb6afb43e07dd7e806028a883eaeafd32d5f5277d6419363ecb1475286a61996a4bb4b325b703d3bd60381227af0a3826f7f119a451086bcb5b13a525184d1b2a941ab9a270d2c9c8e584162c5857b138a38c33892e2a921" + "sig": "06fbee223da0becf620e99dc8a68e127c475a3818adaa76c73bf3650baa3768c89dab60f701c8621f808891f30ebfce31ee4c1859cbf4a6e2e08990364d930755713142efe9e244e0ea3ad7468d7c1540f452cd8d890f8899363952bce943b5dd3e2e5918f81d7f1b1e91d2269b61a042611d9150ebfe79f4fca1fdca0006ca3" } ], "signed": { "_type": "snapshot", - "expires": "2030-08-15T14:30:45.0000001Z", + "expires": "2030-08-15T14:30:45Z", "meta": { "role1.json": { "version": 1 diff --git a/internal/testutils/repository_data/repository/metadata/targets.json b/internal/testutils/repository_data/repository/metadata/targets.json index 1e06afa0..5e34ccea 100644 --- a/internal/testutils/repository_data/repository/metadata/targets.json +++ b/internal/testutils/repository_data/repository/metadata/targets.json @@ -2,7 +2,7 @@ "signatures": [ { "keyid": "282612f348dcd7fe3f19e0f890e89fad48d45335deeb91deef92873934e6fe6d", - "sig": "699c0c762032f6471fceabfd2d61b80f352ad6bf2ba5fd7b114fd0b9b1ab8d94f1482776b3fef43c53183a9c9cd7f5de671cbdafdd5032bbe2c42273e953bf3ce9f99c2d46dac8802d6155082e10313e22c4886af2be113b626f2a8af930e01ed41df50a5dbe6ca4cedf5f5d2a7f3b7e7090abacc8aebd6e021ad021d3580cad" + "sig": "2f887b7661d41f0a695b543feb8e9928420548fa45e4e0a9585cbce85fcd75d1a4787c0847045c38798cd2cc322ca322f3ac49293e311b60455c815aeab7cda6241cea5380f85ff0d2fb9ebe29feb3cb3600c7c9b203852e286842e3e7cccbb2d3836616fe58cd3fe46da127c9b12a3f6cab43a1e6dd1ea206044bc732f0128d" } ], "signed": { @@ -35,7 +35,7 @@ } ] }, - "expires": "2030-08-15T14:30:45.0000001Z", + "expires": "2030-08-15T14:30:45Z", "spec_version": "1.0.31", "targets": { "file1.txt": { diff --git a/internal/testutils/repository_data/repository/metadata/timestamp.json b/internal/testutils/repository_data/repository/metadata/timestamp.json index c361c552..ef5b84b4 100644 --- a/internal/testutils/repository_data/repository/metadata/timestamp.json +++ b/internal/testutils/repository_data/repository/metadata/timestamp.json @@ -2,12 +2,12 @@ "signatures": [ { "keyid": "142919f8e933d7045abff3be450070057814da36331d7a22ccade8b35a9e3946", - "sig": "043b312da9ee1444e3ef539d943891b2690a8d75624c0f05ec148790fd698a7eb1501428167872794857e9669a451619cc796658782b1d46ecc59d1aca0db7233416e81074ef4f54fd845ad8e4216b4cd5163d815be9ecbf73f34aacd25b60c99da88cf641ba5715c37f34a6bc036061c05a42066f554714ee8647c47ae5c16e" + "sig": "25e412c3e45775754eea566b1030ba40277bfa1b6740894c1f93abb2899d1e21690f06068d96a2197cad80b587754750d8de1e4644a7552689d13245af7794921dad00596b4c1b52b37735d873dab1131a3b408badec3414724731521c38b17bfed33d04a1573a6bd8f6510e98d66036cdecb6ff1657be9e5d59b84674f6471c" } ], "signed": { "_type": "timestamp", - "expires": "2030-08-15T14:30:45.0000001Z", + "expires": "2030-08-15T14:30:45Z", "meta": { "snapshot.json": { "version": 1 diff --git a/metadata/expires_format_test.go b/metadata/expires_format_test.go new file mode 100644 index 00000000..396bcc7e --- /dev/null +++ b/metadata/expires_format_test.go @@ -0,0 +1,104 @@ +// Copyright 2024 The Update Framework Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License +// +// SPDX-License-Identifier: Apache-2.0 +// + +package metadata + +import ( + "encoding/json" + "regexp" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +// secondPrecisionUTC matches the TUF spec date/time format for "expires": +// an ISO 8601 / RFC 3339 timestamp in UTC, truncated to whole seconds with +// a trailing Z and no fractional-second component. +var secondPrecisionUTC = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$`) + +// expiresFromJSON pulls the raw "expires" string out of a marshaled signed body. +func expiresFromJSON(t *testing.T, data []byte) string { + t.Helper() + var dict map[string]any + if err := json.Unmarshal(data, &dict); err != nil { + t.Fatalf("unmarshal signed body: %v", err) + } + v, ok := dict["expires"] + if !ok { + t.Fatalf("no expires field in %s", string(data)) + } + s, ok := v.(string) + if !ok { + t.Fatalf("expires is not a string: %T (%v)", v, v) + } + return s +} + +// TestExpiresMarshalSecondPrecision verifies that every role type serializes +// "expires" with whole-second UTC precision (YYYY-MM-DDTHH:MM:SSZ), per the TUF +// spec, even when the in-memory time.Time carries sub-second precision. +func TestExpiresMarshalSecondPrecision(t *testing.T) { + // A time with sub-second precision (100 nanoseconds). Marshaling a raw + // time.Time would emit "2030-08-15T14:30:45.0000001Z". + subSecond := time.Date(2030, 8, 15, 14, 30, 45, 100, time.UTC) + wantExpires := "2030-08-15T14:30:45Z" + + cases := []struct { + name string + body json.Marshaler + }{ + {"root", Root(subSecond).Signed}, + {"snapshot", Snapshot(subSecond).Signed}, + {"timestamp", Timestamp(subSecond).Signed}, + {"targets", Targets(subSecond).Signed}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data, err := tc.body.MarshalJSON() + assert.NoError(t, err) + + got := expiresFromJSON(t, data) + assert.Regexp(t, secondPrecisionUTC, got, + "expires must be whole-second UTC per the TUF spec, got %q", got) + assert.Equal(t, wantExpires, got) + }) + } +} + +// TestExpiresMarshalUnmarshalRoundTrip confirms that the second-precision +// formatting still round-trips: parsing the formatted output back yields the +// truncated time, and re-marshaling is stable. +func TestExpiresMarshalUnmarshalRoundTrip(t *testing.T) { + subSecond := time.Date(2030, 8, 15, 14, 30, 45, 100, time.UTC) + truncated := time.Date(2030, 8, 15, 14, 30, 45, 0, time.UTC) + + root := Root(subSecond) + data, err := root.Signed.MarshalJSON() + assert.NoError(t, err) + + var parsed RootType + assert.NoError(t, parsed.UnmarshalJSON(data)) + assert.True(t, parsed.Expires.Equal(truncated), + "round-tripped expires %v should equal %v", parsed.Expires, truncated) + + // Re-marshaling the parsed value is stable and still second-precision. + data2, err := parsed.MarshalJSON() + assert.NoError(t, err) + assert.Equal(t, "2030-08-15T14:30:45Z", expiresFromJSON(t, data2)) +} diff --git a/metadata/marshal.go b/metadata/marshal.go index e88bfd47..10981eb5 100644 --- a/metadata/marshal.go +++ b/metadata/marshal.go @@ -23,8 +23,23 @@ import ( "errors" "fmt" "maps" + "time" ) +// expiresFormat is the TUF spec date/time layout for the "expires" field: +// an ISO 8601 / RFC 3339 timestamp in UTC truncated to whole seconds, e.g. +// "2030-01-01T00:00:00Z". A raw time.Time would otherwise be serialized by +// encoding/json with sub-second (RFC3339Nano) precision, which is not valid +// per the spec. +const expiresFormat = "2006-01-02T15:04:05Z" + +// formatExpires renders an "expires" time in the spec-required second-precision +// UTC format. It is the single source of truth shared by the role MarshalJSON +// methods so they always agree. +func formatExpires(t time.Time) string { + return t.UTC().Format(expiresFormat) +} + // The following marshal/unmarshal methods override the default behavior for for each TUF type // in order to support unrecognized fields @@ -37,7 +52,7 @@ func (signed RootType) MarshalJSON() ([]byte, error) { dict["spec_version"] = signed.SpecVersion dict["consistent_snapshot"] = signed.ConsistentSnapshot dict["version"] = signed.Version - dict["expires"] = signed.Expires + dict["expires"] = formatExpires(signed.Expires) dict["keys"] = signed.Keys dict["roles"] = signed.Roles return json.Marshal(dict) @@ -74,7 +89,7 @@ func (signed SnapshotType) MarshalJSON() ([]byte, error) { dict["_type"] = signed.Type dict["spec_version"] = signed.SpecVersion dict["version"] = signed.Version - dict["expires"] = signed.Expires + dict["expires"] = formatExpires(signed.Expires) dict["meta"] = signed.Meta return json.Marshal(dict) } @@ -108,7 +123,7 @@ func (signed TimestampType) MarshalJSON() ([]byte, error) { dict["_type"] = signed.Type dict["spec_version"] = signed.SpecVersion dict["version"] = signed.Version - dict["expires"] = signed.Expires + dict["expires"] = formatExpires(signed.Expires) dict["meta"] = signed.Meta return json.Marshal(dict) } @@ -142,7 +157,7 @@ func (signed TargetsType) MarshalJSON() ([]byte, error) { dict["_type"] = signed.Type dict["spec_version"] = signed.SpecVersion dict["version"] = signed.Version - dict["expires"] = signed.Expires + dict["expires"] = formatExpires(signed.Expires) dict["targets"] = signed.Targets if signed.Delegations != nil { dict["delegations"] = signed.Delegations diff --git a/metadata/metadata_test.go b/metadata/metadata_test.go index 13838644..7d336a44 100644 --- a/metadata/metadata_test.go +++ b/metadata/metadata_test.go @@ -376,25 +376,25 @@ func TestUnrecognizedFieldRolesSigned(t *testing.T) { root.Signed.UnrecognizedFields = testUnrecognizedField rootJSON, err := root.ToBytes(false) assert.NoError(t, err) - assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"keys\":{},\"roles\":{\"root\":{\"keyids\":[],\"threshold\":1},\"snapshot\":{\"keyids\":[],\"threshold\":1},\"targets\":{\"keyids\":[],\"threshold\":1},\"timestamp\":{\"keyids\":[],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), rootJSON) + assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45Z\",\"keys\":{},\"roles\":{\"root\":{\"keyids\":[],\"threshold\":1},\"snapshot\":{\"keyids\":[],\"threshold\":1},\"targets\":{\"keyids\":[],\"threshold\":1},\"timestamp\":{\"keyids\":[],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), rootJSON) targets := Targets(fixedExpire) targets.Signed.UnrecognizedFields = testUnrecognizedField targetsJSON, err := targets.ToBytes(false) assert.NoError(t, err) - assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"targets\",\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"spec_version\":\"1.0.31\",\"targets\":{},\"test\":\"true\",\"version\":1}}"), targetsJSON) + assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"targets\",\"expires\":\"2030-08-15T14:30:45Z\",\"spec_version\":\"1.0.31\",\"targets\":{},\"test\":\"true\",\"version\":1}}"), targetsJSON) snapshot := Snapshot(fixedExpire) snapshot.Signed.UnrecognizedFields = testUnrecognizedField snapshotJSON, err := snapshot.ToBytes(false) assert.NoError(t, err) - assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"snapshot\",\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"meta\":{\"targets.json\":{\"version\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), snapshotJSON) + assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"snapshot\",\"expires\":\"2030-08-15T14:30:45Z\",\"meta\":{\"targets.json\":{\"version\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), snapshotJSON) timestamp := Timestamp(fixedExpire) timestamp.Signed.UnrecognizedFields = testUnrecognizedField timestampJSON, err := timestamp.ToBytes(false) assert.NoError(t, err) - assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"timestamp\",\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"meta\":{\"snapshot.json\":{\"version\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), timestampJSON) + assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"timestamp\",\"expires\":\"2030-08-15T14:30:45Z\",\"meta\":{\"snapshot.json\":{\"version\":1}},\"spec_version\":\"1.0.31\",\"test\":\"true\",\"version\":1}}"), timestampJSON) } func TestUnrecognizedFieldGenericMetadata(t *testing.T) { // fixed expire @@ -408,7 +408,7 @@ func TestUnrecognizedFieldGenericMetadata(t *testing.T) { root.UnrecognizedFields = testUnrecognizedField rootJSON, err := root.ToBytes(false) assert.NoError(t, err) - assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"keys\":{},\"roles\":{\"root\":{\"keyids\":[],\"threshold\":1},\"snapshot\":{\"keyids\":[],\"threshold\":1},\"targets\":{\"keyids\":[],\"threshold\":1},\"timestamp\":{\"keyids\":[],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"version\":1},\"test\":\"true\"}"), rootJSON) + assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45Z\",\"keys\":{},\"roles\":{\"root\":{\"keyids\":[],\"threshold\":1},\"snapshot\":{\"keyids\":[],\"threshold\":1},\"targets\":{\"keyids\":[],\"threshold\":1},\"timestamp\":{\"keyids\":[],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"version\":1},\"test\":\"true\"}"), rootJSON) } func TestTargetFilesCustomField(t *testing.T) { // custom JSON to test @@ -426,7 +426,7 @@ func TestTargetFilesCustomField(t *testing.T) { targets.Signed.Targets["testTarget"] = targetFile targetsJSON, err := targets.ToBytes(false) assert.NoError(t, err) - assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"targets\",\"expires\":\"2030-08-15T14:30:45.0000001Z\",\"spec_version\":\"1.0.31\",\"targets\":{\"testTarget\":{\"custom\":{\"test\":true},\"hashes\":{},\"length\":0}},\"version\":1}}"), targetsJSON) + assert.Equal(t, []byte("{\"signatures\":[],\"signed\":{\"_type\":\"targets\",\"expires\":\"2030-08-15T14:30:45Z\",\"spec_version\":\"1.0.31\",\"targets\":{\"testTarget\":{\"custom\":{\"test\":true},\"hashes\":{},\"length\":0}},\"version\":1}}"), targetsJSON) } func TestFromBytes(t *testing.T) { @@ -509,7 +509,10 @@ func TestToByte(t *testing.T) { root.Signatures = append(root.Signatures, Signature{KeyID: "roothash", Signature: hash["ed25519"]}) rootBytes, err := root.ToBytes(false) assert.NoError(t, err) - assert.Equal(t, string(testRootBytes), string(rootBytes)) + // Even though the input expires string carries sub-second precision, the + // serialized output must use the spec-required whole-second UTC format. + expectedRootBytes := []byte("{\"signatures\":[{\"keyid\":\"roothash\",\"sig\":\"1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee\"}],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45Z\",\"keys\":{\"roothash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubrootval\"},\"scheme\":\"ed25519\"},\"snapshothash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubsval\"},\"scheme\":\"ed25519\"},\"targetshash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubtrval\"},\"scheme\":\"ed25519\"},\"timestamphash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubtmval\"},\"scheme\":\"ed25519\"}},\"roles\":{\"root\":{\"keyids\":[\"roothash\"],\"threshold\":1},\"snapshot\":{\"keyids\":[\"snapshothash\"],\"threshold\":1},\"targets\":{\"keyids\":[\"targetshash\"],\"threshold\":1},\"timestamp\":{\"keyids\":[\"timestamphash\"],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"version\":1}}") + assert.Equal(t, string(expectedRootBytes), string(rootBytes)) } func TestFromFile(t *testing.T) { @@ -573,7 +576,10 @@ func TestToFile(t *testing.T) { assert.FileExists(t, fileName) data, err := os.ReadFile(fileName) assert.NoError(t, err) - assert.Equal(t, string(testRootBytes), string(data)) + // The input bytes carry a sub-second expires; the written output must use + // the spec-required whole-second UTC format. + expectedBytes := []byte("{\"signatures\":[{\"keyid\":\"roothash\",\"sig\":\"1307990e6ba5ca145eb35e99182a9bec46531bc54ddf656a602c780fa0240dee\"}],\"signed\":{\"_type\":\"root\",\"consistent_snapshot\":true,\"expires\":\"2030-08-15T14:30:45Z\",\"keys\":{\"roothash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubrootval\"},\"scheme\":\"ed25519\"},\"snapshothash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubsval\"},\"scheme\":\"ed25519\"},\"targetshash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubtrval\"},\"scheme\":\"ed25519\"},\"timestamphash\":{\"keytype\":\"ed25519\",\"keyval\":{\"public\":\"pubtmval\"},\"scheme\":\"ed25519\"}},\"roles\":{\"root\":{\"keyids\":[\"roothash\"],\"threshold\":1},\"snapshot\":{\"keyids\":[\"snapshothash\"],\"threshold\":1},\"targets\":{\"keyids\":[\"targetshash\"],\"threshold\":1},\"timestamp\":{\"keyids\":[\"timestamphash\"],\"threshold\":1}},\"spec_version\":\"1.0.31\",\"version\":1}}") + assert.Equal(t, string(expectedBytes), string(data)) err = os.RemoveAll(tmpDir) assert.NoError(t, err)