File: rekor.go

package info (click to toggle)
golang-github-containers-image 5.36.1-1
  • links: PTS, VCS
  • area: main
  • in suites: experimental
  • size: 5,152 kB
  • sloc: sh: 267; makefile: 100
file content (179 lines) | stat: -rw-r--r-- 6,741 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
package rekor

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"strings"

	"github.com/containers/image/v5/signature/internal"
	signerInternal "github.com/containers/image/v5/signature/sigstore/internal"
	"github.com/hashicorp/go-cleanhttp"
	"github.com/hashicorp/go-retryablehttp"
	"github.com/sirupsen/logrus"
)

const (
	// defaultRetryCount is the default number of retries
	defaultRetryCount = 3
)

// WithRekor asks the generated signature to be uploaded to the specified Rekor server,
// and to include a log inclusion proof in the signature.
func WithRekor(rekorURL *url.URL) signerInternal.Option {
	return func(s *signerInternal.SigstoreSigner) error {
		logrus.Debugf("Using Rekor server at %s", rekorURL.Redacted())
		client := newRekorClient(rekorURL)
		s.RekorUploader = client.uploadKeyOrCert
		return nil
	}
}

// rekorClient allows uploading entries to Rekor.
type rekorClient struct {
	rekorURL   *url.URL // Only Scheme and Host is actually used, consistent with github.com/sigstore/rekor/pkg/client.
	basePath   string
	httpClient *http.Client
}

// newRekorClient creates a rekorClient for rekorURL.
func newRekorClient(rekorURL *url.URL) *rekorClient {
	retryableClient := retryablehttp.NewClient()
	retryableClient.HTTPClient = cleanhttp.DefaultClient()
	retryableClient.RetryMax = defaultRetryCount
	retryableClient.Logger = leveledLoggerForLogrus(logrus.StandardLogger())
	basePath := rekorURL.Path
	if !strings.HasPrefix(basePath, "/") { // Includes basePath == "", i.e. URL just a https://hostname
		basePath = "/" + basePath
	}
	return &rekorClient{
		rekorURL:   rekorURL,
		basePath:   basePath,
		httpClient: retryableClient.StandardClient(),
	}
}

// rekorEntryToSET converts a Rekor log entry into a sigstore “signed entry timestamp”.
func rekorEntryToSET(entry *rekorLogEntryAnon) (internal.UntrustedRekorSET, error) {
	// We could plausibly call entry.Validate() here; that mostly just uses unnecessary reflection instead of direct == nil checks.
	// Right now the only extra validation .Validate() does is *entry.LogIndex >= 0 and a regex check on *entry.LogID;
	// we don’t particularly care about either of these (notably signature verification only uses the Body value).
	if entry.Verification == nil || entry.IntegratedTime == nil || entry.LogIndex == nil || entry.LogID == nil {
		return internal.UntrustedRekorSET{}, fmt.Errorf("invalid Rekor entry (missing data): %#v", *entry)
	}
	bodyBase64, ok := entry.Body.(string)
	if !ok {
		return internal.UntrustedRekorSET{}, fmt.Errorf("unexpected Rekor entry body type: %#v", entry.Body)
	}
	body, err := base64.StdEncoding.DecodeString(bodyBase64)
	if err != nil {
		return internal.UntrustedRekorSET{}, fmt.Errorf("error parsing Rekor entry body: %w", err)
	}
	payloadJSON, err := internal.UntrustedRekorPayload{
		Body:           body,
		IntegratedTime: *entry.IntegratedTime,
		LogIndex:       *entry.LogIndex,
		LogID:          *entry.LogID,
	}.MarshalJSON()
	if err != nil {
		return internal.UntrustedRekorSET{}, err
	}

	return internal.UntrustedRekorSET{
		UntrustedSignedEntryTimestamp: entry.Verification.SignedEntryTimestamp,
		UntrustedPayload:              payloadJSON,
	}, nil
}

// uploadEntry ensures proposedEntry exists in Rekor (usually uploading it), and returns the resulting log entry.
func (r *rekorClient) uploadEntry(ctx context.Context, proposedEntry rekorProposedEntry) (rekorLogEntry, error) {
	logrus.Debugf("Calling Rekor's CreateLogEntry")
	resp, err := r.createLogEntry(ctx, proposedEntry)
	if err != nil {
		// In ordinary operation, we should not get duplicate entries, because our payload contains a timestamp,
		// so it is supposed to be unique; and the default key format, ECDSA p256, also contains a nonce.
		// But conflicts can fairly easily happen during debugging and experimentation, so it pays to handle this.
		var conflictErr *createLogEntryConflictError
		if errors.As(err, &conflictErr) && conflictErr.location != "" {
			location := conflictErr.location
			logrus.Debugf("CreateLogEntry reported a conflict, location = %s", location)
			// We might be able to just GET the returned Location, but let’s use the formal API method.
			// OTOH that requires us to hard-code the URI structure…
			uuidDelimiter := strings.LastIndexByte(location, '/')
			if uuidDelimiter != -1 { // Otherwise the URI is unexpected, and fall through to the bottom
				uuid := location[uuidDelimiter+1:]
				logrus.Debugf("Calling Rekor's NewGetLogEntryByUUIDParamsWithContext")
				resp2, err := r.getLogEntryByUUID(ctx, uuid)
				if err != nil {
					return nil, fmt.Errorf("Error re-loading previously-created log entry with UUID %s: %w", uuid, err)
				}
				return resp2, nil
			}
		}
		return nil, fmt.Errorf("Error uploading a log entry: %w", err)
	}
	return resp, nil
}

// stringPointer is a helper to create *string fields in JSON data.
func stringPointer(s string) *string {
	return &s
}

// uploadKeyOrCert integrates this code into sigstore/internal.Signer.
// Given components of the created signature, it returns a SET that should be added to the signature.
func (r *rekorClient) uploadKeyOrCert(ctx context.Context, keyOrCertBytes []byte, signatureBytes []byte, payloadBytes []byte) ([]byte, error) {
	payloadHash := sha256.Sum256(payloadBytes) // Consistent with cosign.
	hashedRekordSpec, err := json.Marshal(internal.RekorHashedrekordV001Schema{
		Data: &internal.RekorHashedrekordV001SchemaData{
			Hash: &internal.RekorHashedrekordV001SchemaDataHash{
				Algorithm: stringPointer(internal.RekorHashedrekordV001SchemaDataHashAlgorithmSha256),
				Value:     stringPointer(hex.EncodeToString(payloadHash[:])),
			},
		},
		Signature: &internal.RekorHashedrekordV001SchemaSignature{
			Content: signatureBytes,
			PublicKey: &internal.RekorHashedrekordV001SchemaSignaturePublicKey{
				Content: keyOrCertBytes,
			},
		},
	})
	if err != nil {
		return nil, err
	}
	proposedEntry := internal.RekorHashedrekord{
		APIVersion: stringPointer(internal.RekorHashedRekordV001APIVersion),
		Spec:       hashedRekordSpec,
	}

	uploadedPayload, err := r.uploadEntry(ctx, &proposedEntry)
	if err != nil {
		return nil, err
	}

	if len(uploadedPayload) != 1 {
		return nil, fmt.Errorf("expected 1 Rekor entry, got %d", len(uploadedPayload))
	}
	var storedEntry *rekorLogEntryAnon
	// This “loop” extracts the single value from the uploadedPayload map.
	for _, p := range uploadedPayload {
		storedEntry = &p
		break
	}

	rekorBundle, err := rekorEntryToSET(storedEntry)
	if err != nil {
		return nil, err
	}
	rekorSETBytes, err := json.Marshal(rekorBundle)
	if err != nil {
		return nil, err
	}
	return rekorSETBytes, nil
}