Skip to content

Format expires with second precision per TUF spec#736

Open
arpitjain099 wants to merge 1 commit into
theupdateframework:masterfrom
arpitjain099:fix/expires-second-precision
Open

Format expires with second precision per TUF spec#736
arpitjain099 wants to merge 1 commit into
theupdateframework:masterfrom
arpitjain099:fix/expires-second-precision

Conversation

@arpitjain099

Copy link
Copy Markdown

What this does

Fixes the expires serialization so it conforms to the TUF spec date/time format.

The four role MarshalJSON methods in metadata/marshal.go (RootType, SnapshotType, TimestampType, TargetsType) wrote the expires field as a raw time.Time:

dict["expires"] = signed.Expires

encoding/json serializes a time.Time with RFC3339Nano, so any sub-second component leaks into the output, 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.

Changes

  • Add a small formatExpires helper plus an expiresFormat layout constant in metadata/marshal.go, and use the helper at all four marshal sites so they agree on a single format.
  • Unmarshal is left untouched. It still accepts and round-trips sub-second input (parsing simply truncates to whole seconds on the next marshal).
  • Add metadata/expires_format_test.go:
    • TestExpiresMarshalSecondPrecision marshals each of the four role types built from a time.Time that carries sub-second precision and asserts the emitted expires matches ^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$.
    • TestExpiresMarshalUnmarshalRoundTrip marshals, unmarshals, and confirms the value round-trips and re-marshals stably.
  • Update the existing marshal-output golden assertions in metadata/metadata_test.go that hard-coded the old sub-second output (...45.0000001Z) to the corrected ...45Z. The sub-second testRootBytes literal is kept as parse-direction input so the suite still exercises sub-second tolerance on unmarshal.
  • Regenerate the on-disk signed fixtures (root.json, snapshot.json, targets.json, timestamp.json under internal/testutils/repository_data/repository/metadata) so their rsassa-pss-sha256 signatures match the corrected second-precision signed body. Each fixture changes only two values: the expires string and the corresponding signature. The fixtures were re-signed with their existing keystore keys using the repo's own internal/testutils/rsapss.LoadRSAPSSSignerFromPEMFile helper, so the scheme is unchanged.

The legacy parse-only fixture 1.root.json is intentionally left as-is: it is consumed by TestFromFile/TestFromBytes to assert sub-second parse tolerance, is never re-serialized or signature-verified in tests, and is signed by a key whose private half is not in the keystore.

Testing

go test ./metadata/...
gofmt -l metadata/marshal.go metadata/metadata_test.go metadata/expires_format_test.go   # no output
go vet ./metadata/...                                                                      # clean

go vet and gofmt are clean. The new tests fail on the unpatched code (all four roles emit sub-second precision) and pass after the fix. TestCompareFromBytesFromFileToBytes, TestToFromBytes, TestKeyVerifyFailures, and TestMetadataVerifyDelegate continue to pass with the regenerated fixtures.

Fixes #596

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 theupdateframework#596

Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
@arpitjain099 arpitjain099 requested a review from a team as a code owner June 2, 2026 23:16
Copilot AI review requested due to automatic review settings June 2, 2026 23:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR updates TUF metadata JSON serialization to emit the expires timestamp in spec-required whole-second UTC format (no fractional seconds) and aligns tests/fixtures accordingly.

Changes:

  • Added centralized expires formatting helper and applied it to all role MarshalJSON implementations.
  • Updated existing unit tests to expect second-precision expires strings.
  • Added dedicated tests + updated repository JSON fixtures/signatures to reflect the new canonical expires formatting.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

File Description
metadata/marshal.go Formats expires as whole-second UTC during JSON marshaling for all role types.
metadata/metadata_test.go Updates expected serialized JSON in multiple tests to match second-precision expires.
metadata/expires_format_test.go Adds focused unit tests to enforce/verify second-precision UTC expires formatting and round-trip behavior.
internal/testutils/repository_data/repository/metadata/*.json Updates test repository metadata fixtures (including signatures) to reflect new expires canonicalization.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread metadata/marshal.go
return t.UTC().Format(expiresFormat)
}

// The following marshal/unmarshal methods override the default behavior for for each TUF type
Comment thread metadata/metadata_test.go
Comment on lines +514 to +515
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))
Comment thread metadata/metadata_test.go
Comment on lines +581 to +582
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))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

bug: use the correct format for "expires" (should not include milliseconds)

2 participants