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
}
|