diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-16 17:39:05 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-16 17:39:05 +0000 |
commit | d3911883df1e317b23fa12be7e1c7b45f74d630a (patch) | |
tree | 154cebf32f39b26b8a88e9bb359c57288d629e3b /data | |
parent | Initial commit. (diff) | |
download | golang-github-theupdateframework-go-tuf-d3911883df1e317b23fa12be7e1c7b45f74d630a.tar.xz golang-github-theupdateframework-go-tuf-d3911883df1e317b23fa12be7e1c7b45f74d630a.zip |
Adding upstream version 0.6.1.upstream/0.6.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'data')
-rw-r--r-- | data/hex_bytes.go | 42 | ||||
-rw-r--r-- | data/hex_bytes_test.go | 44 | ||||
-rw-r--r-- | data/types.go | 348 | ||||
-rw-r--r-- | data/types_test.go | 287 |
4 files changed, 721 insertions, 0 deletions
diff --git a/data/hex_bytes.go b/data/hex_bytes.go new file mode 100644 index 0000000..ec20041 --- /dev/null +++ b/data/hex_bytes.go @@ -0,0 +1,42 @@ +package data + +import ( + "crypto/sha256" + "encoding/hex" + "errors" +) + +type HexBytes []byte + +func (b *HexBytes) UnmarshalJSON(data []byte) error { + if len(data) < 2 || len(data)%2 != 0 || data[0] != '"' || data[len(data)-1] != '"' { + return errors.New("tuf: invalid JSON hex bytes") + } + res := make([]byte, hex.DecodedLen(len(data)-2)) + _, err := hex.Decode(res, data[1:len(data)-1]) + if err != nil { + return err + } + *b = res + return nil +} + +func (b HexBytes) MarshalJSON() ([]byte, error) { + res := make([]byte, hex.EncodedLen(len(b))+2) + res[0] = '"' + res[len(res)-1] = '"' + hex.Encode(res[1:], b) + return res, nil +} + +func (b HexBytes) String() string { + return hex.EncodeToString(b) +} + +// 4.5. File formats: targets.json and delegated target roles: +// ...each target path, when hashed with the SHA-256 hash function to produce +// a 64-byte hexadecimal digest (HEX_DIGEST)... +func PathHexDigest(s string) string { + b := sha256.Sum256([]byte(s)) + return hex.EncodeToString(b[:]) +} diff --git a/data/hex_bytes_test.go b/data/hex_bytes_test.go new file mode 100644 index 0000000..8c11623 --- /dev/null +++ b/data/hex_bytes_test.go @@ -0,0 +1,44 @@ +package data + +import ( + "encoding/json" + "testing" + + . "gopkg.in/check.v1" +) + +// Hook up gocheck into the "go test" runner. +func Test(t *testing.T) { TestingT(t) } + +type HexBytesSuite struct{} + +var _ = Suite(&HexBytesSuite{}) + +func (HexBytesSuite) TestUnmarshalJSON(c *C) { + var data HexBytes + err := json.Unmarshal([]byte(`"666f6f"`), &data) + c.Assert(err, IsNil) + c.Assert(string(data), Equals, "foo") +} + +func (HexBytesSuite) TestUnmarshalJSONError(c *C) { + var data HexBytes + + // uneven length + err := json.Unmarshal([]byte(`"a"`), &data) + c.Assert(err, Not(IsNil)) + + // invalid hex + err = json.Unmarshal([]byte(`"zz"`), &data) + c.Assert(err, Not(IsNil)) + + // wrong type + err = json.Unmarshal([]byte("6"), &data) + c.Assert(err, Not(IsNil)) +} + +func (HexBytesSuite) TestMarshalJSON(c *C) { + data, err := json.Marshal(HexBytes("foo")) + c.Assert(err, IsNil) + c.Assert(data, DeepEquals, []byte(`"666f6f"`)) +} diff --git a/data/types.go b/data/types.go new file mode 100644 index 0000000..eb00489 --- /dev/null +++ b/data/types.go @@ -0,0 +1,348 @@ +package data + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "path" + "strings" + "sync" + "time" + + "github.com/secure-systems-lab/go-securesystemslib/cjson" +) + +type KeyType string + +type KeyScheme string + +type HashAlgorithm string + +const ( + KeyIDLength = sha256.Size * 2 + + KeyTypeEd25519 KeyType = "ed25519" + // From version 1.0.32, the reference implementation defines 'ecdsa', + // not 'ecdsa-sha2-nistp256' for NIST P-256 curves. + KeyTypeECDSA_SHA2_P256 KeyType = "ecdsa" + KeyTypeECDSA_SHA2_P256_OLD_FMT KeyType = "ecdsa-sha2-nistp256" + KeyTypeRSASSA_PSS_SHA256 KeyType = "rsa" + + KeySchemeEd25519 KeyScheme = "ed25519" + KeySchemeECDSA_SHA2_P256 KeyScheme = "ecdsa-sha2-nistp256" + KeySchemeRSASSA_PSS_SHA256 KeyScheme = "rsassa-pss-sha256" + + HashAlgorithmSHA256 HashAlgorithm = "sha256" + HashAlgorithmSHA512 HashAlgorithm = "sha512" +) + +var ( + HashAlgorithms = []HashAlgorithm{HashAlgorithmSHA256, HashAlgorithmSHA512} + ErrPathsAndPathHashesSet = errors.New("tuf: failed validation of delegated target: paths and path_hash_prefixes are both set") +) + +type Signed struct { + Signed json.RawMessage `json:"signed"` + Signatures []Signature `json:"signatures"` +} + +type Signature struct { + KeyID string `json:"keyid"` + Signature HexBytes `json:"sig"` +} + +type PublicKey struct { + Type KeyType `json:"keytype"` + Scheme KeyScheme `json:"scheme"` + Algorithms []HashAlgorithm `json:"keyid_hash_algorithms,omitempty"` + Value json.RawMessage `json:"keyval"` + + ids []string + idOnce sync.Once +} + +type PrivateKey struct { + Type KeyType `json:"keytype"` + Scheme KeyScheme `json:"scheme,omitempty"` + Algorithms []HashAlgorithm `json:"keyid_hash_algorithms,omitempty"` + Value json.RawMessage `json:"keyval"` +} + +func (k *PublicKey) IDs() []string { + k.idOnce.Do(func() { + data, err := cjson.EncodeCanonical(k) + if err != nil { + panic(fmt.Errorf("tuf: error creating key ID: %w", err)) + } + digest := sha256.Sum256(data) + k.ids = []string{hex.EncodeToString(digest[:])} + }) + return k.ids +} + +func (k *PublicKey) ContainsID(id string) bool { + for _, keyid := range k.IDs() { + if id == keyid { + return true + } + } + return false +} + +func DefaultExpires(role string) time.Time { + var t time.Time + switch role { + case "root": + t = time.Now().AddDate(1, 0, 0) + case "snapshot": + t = time.Now().AddDate(0, 0, 7) + case "timestamp": + t = time.Now().AddDate(0, 0, 1) + default: + // targets and delegated targets + t = time.Now().AddDate(0, 3, 0) + } + return t.UTC().Round(time.Second) +} + +type Root struct { + Type string `json:"_type"` + SpecVersion string `json:"spec_version"` + Version int64 `json:"version"` + Expires time.Time `json:"expires"` + Keys map[string]*PublicKey `json:"keys"` + Roles map[string]*Role `json:"roles"` + Custom *json.RawMessage `json:"custom,omitempty"` + + ConsistentSnapshot bool `json:"consistent_snapshot"` +} + +func NewRoot() *Root { + return &Root{ + Type: "root", + SpecVersion: "1.0", + Expires: DefaultExpires("root"), + Keys: make(map[string]*PublicKey), + Roles: make(map[string]*Role), + ConsistentSnapshot: true, + } +} + +func (r *Root) AddKey(key *PublicKey) bool { + changed := false + for _, id := range key.IDs() { + if _, ok := r.Keys[id]; !ok { + changed = true + r.Keys[id] = key + } + } + return changed +} + +type Role struct { + KeyIDs []string `json:"keyids"` + Threshold int `json:"threshold"` +} + +func (r *Role) AddKeyIDs(ids []string) bool { + roleIDs := make(map[string]struct{}) + for _, id := range r.KeyIDs { + roleIDs[id] = struct{}{} + } + changed := false + for _, id := range ids { + if _, ok := roleIDs[id]; !ok { + changed = true + r.KeyIDs = append(r.KeyIDs, id) + } + } + return changed +} + +type Files map[string]TargetFileMeta + +type Hashes map[string]HexBytes + +func (f Hashes) HashAlgorithms() []string { + funcs := make([]string, 0, len(f)) + for name := range f { + funcs = append(funcs, name) + } + return funcs +} + +type metapathFileMeta struct { + Length int64 `json:"length,omitempty"` + Hashes Hashes `json:"hashes,omitempty"` + Version int64 `json:"version"` + Custom *json.RawMessage `json:"custom,omitempty"` +} + +// SnapshotFileMeta is the meta field of a snapshot +// Note: Contains a `custom` field +type SnapshotFileMeta metapathFileMeta + +type SnapshotFiles map[string]SnapshotFileMeta + +type Snapshot struct { + Type string `json:"_type"` + SpecVersion string `json:"spec_version"` + Version int64 `json:"version"` + Expires time.Time `json:"expires"` + Meta SnapshotFiles `json:"meta"` + Custom *json.RawMessage `json:"custom,omitempty"` +} + +func NewSnapshot() *Snapshot { + return &Snapshot{ + Type: "snapshot", + SpecVersion: "1.0", + Expires: DefaultExpires("snapshot"), + Meta: make(SnapshotFiles), + } +} + +type FileMeta struct { + Length int64 `json:"length"` + Hashes Hashes `json:"hashes"` +} + +type TargetFiles map[string]TargetFileMeta + +type TargetFileMeta struct { + FileMeta + Custom *json.RawMessage `json:"custom,omitempty"` +} + +func (f TargetFileMeta) HashAlgorithms() []string { + return f.FileMeta.Hashes.HashAlgorithms() +} + +type Targets struct { + Type string `json:"_type"` + SpecVersion string `json:"spec_version"` + Version int64 `json:"version"` + Expires time.Time `json:"expires"` + Targets TargetFiles `json:"targets"` + Delegations *Delegations `json:"delegations,omitempty"` + Custom *json.RawMessage `json:"custom,omitempty"` +} + +// Delegations represents the edges from a parent Targets role to one or more +// delegated target roles. See spec v1.0.19 section 4.5. +type Delegations struct { + Keys map[string]*PublicKey `json:"keys"` + Roles []DelegatedRole `json:"roles"` +} + +// DelegatedRole describes a delegated role, including what paths it is +// reponsible for. See spec v1.0.19 section 4.5. +type DelegatedRole struct { + Name string `json:"name"` + KeyIDs []string `json:"keyids"` + Threshold int `json:"threshold"` + Terminating bool `json:"terminating"` + PathHashPrefixes []string `json:"path_hash_prefixes,omitempty"` + Paths []string `json:"paths"` +} + +// MatchesPath evaluates whether the path patterns or path hash prefixes match +// a given file. This determines whether a delegated role is responsible for +// signing and verifying the file. +func (d *DelegatedRole) MatchesPath(file string) (bool, error) { + if err := d.validatePaths(); err != nil { + return false, err + } + + for _, pattern := range d.Paths { + if matched, _ := path.Match(pattern, file); matched { + return true, nil + } + } + + pathHash := PathHexDigest(file) + for _, hashPrefix := range d.PathHashPrefixes { + if strings.HasPrefix(pathHash, hashPrefix) { + return true, nil + } + } + + return false, nil +} + +// validatePaths enforces the spec +// https://theupdateframework.github.io/specification/v1.0.19/index.html#file-formats-targets +// 'role MUST specify only one of the "path_hash_prefixes" or "paths"' +// Marshalling and unmarshalling JSON will fail and return +// ErrPathsAndPathHashesSet if both fields are set and not empty. +func (d *DelegatedRole) validatePaths() error { + if len(d.PathHashPrefixes) > 0 && len(d.Paths) > 0 { + return ErrPathsAndPathHashesSet + } + + return nil +} + +// MarshalJSON is called when writing the struct to JSON. We validate prior to +// marshalling to ensure that an invalid delegated role can not be serialized +// to JSON. +func (d *DelegatedRole) MarshalJSON() ([]byte, error) { + type delegatedRoleAlias DelegatedRole + + if err := d.validatePaths(); err != nil { + return nil, err + } + + return json.Marshal((*delegatedRoleAlias)(d)) +} + +// UnmarshalJSON is called when reading the struct from JSON. We validate once +// unmarshalled to ensure that an error is thrown if an invalid delegated role +// is read. +func (d *DelegatedRole) UnmarshalJSON(b []byte) error { + type delegatedRoleAlias DelegatedRole + + // Prepare decoder + dec := json.NewDecoder(bytes.NewReader(b)) + + // Unmarshal delegated role + if err := dec.Decode((*delegatedRoleAlias)(d)); err != nil { + return err + } + + return d.validatePaths() +} + +func NewTargets() *Targets { + return &Targets{ + Type: "targets", + SpecVersion: "1.0", + Expires: DefaultExpires("targets"), + Targets: make(TargetFiles), + } +} + +type TimestampFileMeta metapathFileMeta + +type TimestampFiles map[string]TimestampFileMeta + +type Timestamp struct { + Type string `json:"_type"` + SpecVersion string `json:"spec_version"` + Version int64 `json:"version"` + Expires time.Time `json:"expires"` + Meta TimestampFiles `json:"meta"` + Custom *json.RawMessage `json:"custom,omitempty"` +} + +func NewTimestamp() *Timestamp { + return &Timestamp{ + Type: "timestamp", + SpecVersion: "1.0", + Expires: DefaultExpires("timestamp"), + Meta: make(TimestampFiles), + } +} diff --git a/data/types_test.go b/data/types_test.go new file mode 100644 index 0000000..b9523fa --- /dev/null +++ b/data/types_test.go @@ -0,0 +1,287 @@ +package data + +import ( + "encoding/json" + "testing" + + "github.com/secure-systems-lab/go-securesystemslib/cjson" + "github.com/stretchr/testify/assert" + . "gopkg.in/check.v1" +) + +const ( + // This public key is from the TUF specs: + // + // https://github.com/theupdateframework/specification + // + public = `"72378e5bc588793e58f81c8533da64a2e8f1565c1fcc7f253496394ffc52542c"` + keyid10 = "1bf1c6e3cdd3d3a8420b19199e27511999850f4b376c4547b2f32fba7e80fca3" + keyid10algos = "506a349b85945d0d99c7289c3f0f1f6c550218089d1d38a3f64824db31e827ac" +) + +type TypesSuite struct{} + +var _ = Suite(&TypesSuite{}) + +type ed25519Public struct { + PublicKey HexBytes `json:"public"` +} + +func (TypesSuite) TestKeyIDs(c *C) { + var hexbytes HexBytes + err := json.Unmarshal([]byte(public), &hexbytes) + c.Assert(err, IsNil) + keyValBytes, err := json.Marshal(ed25519Public{PublicKey: hexbytes}) + c.Assert(err, IsNil) + + key := PublicKey{ + Type: KeyTypeEd25519, + Scheme: KeySchemeEd25519, + Value: keyValBytes, + } + c.Assert(key.IDs(), DeepEquals, []string{keyid10}) + + key = PublicKey{ + Type: KeyTypeEd25519, + Scheme: KeySchemeEd25519, + Algorithms: HashAlgorithms, + Value: keyValBytes, + } + c.Assert(key.IDs(), DeepEquals, []string{keyid10algos}) +} + +func (TypesSuite) TestRootAddKey(c *C) { + var hexbytes HexBytes + err := json.Unmarshal([]byte(public), &hexbytes) + c.Assert(err, IsNil) + keyValBytes, err := json.Marshal(ed25519Public{PublicKey: hexbytes}) + c.Assert(err, IsNil) + + key := &PublicKey{ + Type: KeyTypeEd25519, + Scheme: KeySchemeEd25519, + Value: keyValBytes, + } + + root := NewRoot() + + c.Assert(root.AddKey(key), Equals, true) + c.Assert(root.AddKey(key), Equals, false) +} + +func (TypesSuite) TestRoleAddKeyIDs(c *C) { + var hexbytes HexBytes + err := json.Unmarshal([]byte(public), &hexbytes) + c.Assert(err, IsNil) + keyValBytes, err := json.Marshal(ed25519Public{PublicKey: hexbytes}) + c.Assert(err, IsNil) + + key := &PublicKey{ + Type: KeyTypeEd25519, + Scheme: KeySchemeEd25519, + Value: keyValBytes, + } + + role := &Role{} + c.Assert(role.KeyIDs, HasLen, 0) + + c.Assert(role.AddKeyIDs(key.IDs()), Equals, true) + c.Assert(role.KeyIDs, DeepEquals, []string{keyid10}) + + // Adding the key again doesn't modify the array. + c.Assert(role.AddKeyIDs(key.IDs()), Equals, false) + c.Assert(role.KeyIDs, DeepEquals, []string{keyid10}) + + // Add another key. + key = &PublicKey{ + Type: KeyTypeEd25519, + Scheme: KeySchemeEd25519, + Algorithms: HashAlgorithms, + Value: keyValBytes, + } + + // Adding the key again doesn't modify the array. + c.Assert(role.AddKeyIDs(key.IDs()), Equals, true) + c.Assert(role.KeyIDs, DeepEquals, []string{keyid10, keyid10algos}) +} + +func TestDelegatedRolePathMatch(t *testing.T) { + var tts = []struct { + testName string + pathPatterns []string + pathHashPrefixes []string + file string + shouldMatch bool + }{ + { + testName: "no path", + file: "licence.txt", + }, + { + testName: "match path *", + pathPatterns: []string{"null", "targets/*.tgz"}, + file: "targets/foo.tgz", + shouldMatch: true, + }, + { + testName: "does not match path *", + pathPatterns: []string{"null", "targets/*.tgz"}, + file: "targets/foo.txt", + shouldMatch: false, + }, + { + testName: "match path ?", + pathPatterns: []string{"foo-version-?.tgz"}, + file: "foo-version-a.tgz", + shouldMatch: true, + }, + { + testName: "does not match ?", + pathPatterns: []string{"foo-version-?.tgz"}, + file: "foo-version-alpha.tgz", + shouldMatch: false, + }, + // picked from https://github.com/theupdateframework/tuf/blob/30ba6e9f9ab25e0370e29ce574dada2d8809afa0/tests/test_updater.py#L1726-L1734 + { + testName: "match hash prefix", + pathHashPrefixes: []string{"badd", "8baf"}, + file: "/file3.txt", + shouldMatch: true, + }, + { + testName: "does not match hash prefix", + pathHashPrefixes: []string{"badd"}, + file: "/file3.txt", + shouldMatch: false, + }, + { + testName: "hash prefix first char", + pathHashPrefixes: []string{"2"}, + file: "/a/b/c/file_d.txt", + shouldMatch: true, + }, + { + testName: "full hash prefix", + pathHashPrefixes: []string{"34c85d1ee84f61f10d7dc633472a49096ed87f8f764bd597831eac371f40ac39"}, + file: "/e/f/g.txt", + shouldMatch: true, + }, + } + for _, tt := range tts { + t.Run(tt.testName, func(t *testing.T) { + d := DelegatedRole{ + Paths: tt.pathPatterns, + PathHashPrefixes: tt.pathHashPrefixes, + } + assert.NoError(t, d.validatePaths()) + + matchesPath, err := d.MatchesPath(tt.file) + assert.NoError(t, err) + assert.Equal(t, tt.shouldMatch, matchesPath) + }) + + } +} + +func TestDelegatedRoleJSON(t *testing.T) { + var tts = []struct { + testName string + d *DelegatedRole + rawCJSON string + }{{ + testName: "all fields with hashes", + d: &DelegatedRole{ + Name: "n1", + KeyIDs: []string{"k1"}, + Threshold: 5, + Terminating: true, + PathHashPrefixes: []string{"8f"}, + }, + rawCJSON: `{"keyids":["k1"],"name":"n1","path_hash_prefixes":["8f"],"paths":null,"terminating":true,"threshold":5}`, + }, + { + testName: "paths only", + d: &DelegatedRole{ + Name: "n2", + KeyIDs: []string{"k1", "k3"}, + Threshold: 12, + Paths: []string{"*.txt"}, + }, + rawCJSON: `{"keyids":["k1","k3"],"name":"n2","paths":["*.txt"],"terminating":false,"threshold":12}`, + }, + { + testName: "default", + d: &DelegatedRole{}, + rawCJSON: `{"keyids":null,"name":"","paths":null,"terminating":false,"threshold":0}`, + }, + } + + for _, tt := range tts { + t.Run(tt.testName, func(t *testing.T) { + b, err := cjson.EncodeCanonical(tt.d) + assert.NoError(t, err) + assert.Equal(t, tt.rawCJSON, string(b)) + + newD := &DelegatedRole{} + err = json.Unmarshal(b, newD) + assert.NoError(t, err) + assert.Equal(t, tt.d, newD) + }) + } +} + +func TestDelegatedRoleUnmarshalErr(t *testing.T) { + targetsWithBothMatchers := []byte(`{"keyids":null,"name":"","paths":["*.txt"],"path_hash_prefixes":["8f"],"terminating":false,"threshold":0}`) + var d DelegatedRole + assert.Equal(t, ErrPathsAndPathHashesSet, json.Unmarshal(targetsWithBothMatchers, &d)) + + // test for type errors + err := json.Unmarshal([]byte(`{"keyids":"a"}`), &d) + assert.Equal(t, "keyids", err.(*json.UnmarshalTypeError).Field) +} + +func TestCustomField(t *testing.T) { + testCustomJSON := json.RawMessage([]byte(`{"test":true}`)) + + root := Root{ + Type: "root", + SpecVersion: "1.0", + Keys: make(map[string]*PublicKey), + Roles: make(map[string]*Role), + ConsistentSnapshot: true, + Custom: &testCustomJSON, + } + rootJSON, err := json.Marshal(&root) + assert.NoError(t, err) + assert.Equal(t, []byte("{\"_type\":\"root\",\"spec_version\":\"1.0\",\"version\":0,\"expires\":\"0001-01-01T00:00:00Z\",\"keys\":{},\"roles\":{},\"custom\":{\"test\":true},\"consistent_snapshot\":true}"), rootJSON) + + targets := Targets{ + Type: "targets", + SpecVersion: "1.0", + Targets: make(TargetFiles), + Custom: &testCustomJSON, + } + targetsJSON, err := json.Marshal(&targets) + assert.NoError(t, err) + assert.Equal(t, []byte("{\"_type\":\"targets\",\"spec_version\":\"1.0\",\"version\":0,\"expires\":\"0001-01-01T00:00:00Z\",\"targets\":{},\"custom\":{\"test\":true}}"), targetsJSON) + + snapshot := Snapshot{ + Type: "snapshot", + SpecVersion: "1.0", + Meta: make(SnapshotFiles), + Custom: &testCustomJSON, + } + snapshotJSON, err := json.Marshal(&snapshot) + assert.NoError(t, err) + assert.Equal(t, []byte("{\"_type\":\"snapshot\",\"spec_version\":\"1.0\",\"version\":0,\"expires\":\"0001-01-01T00:00:00Z\",\"meta\":{},\"custom\":{\"test\":true}}"), snapshotJSON) + + timestamp := Timestamp{ + Type: "timestamp", + SpecVersion: "1.0", + Meta: make(TimestampFiles), + Custom: &testCustomJSON, + } + timestampJSON, err := json.Marshal(×tamp) + assert.NoError(t, err) + assert.Equal(t, []byte("{\"_type\":\"timestamp\",\"spec_version\":\"1.0\",\"version\":0,\"expires\":\"0001-01-01T00:00:00Z\",\"meta\":{},\"custom\":{\"test\":true}}"), timestampJSON) +} |