blob: f7b399db215bad67de2e4083ec467aec9621d1fa [file] [log] [blame]
// Copyright 2023 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package bcid
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"os/exec"
"runtime"
spb "github.com/in-toto/attestation/go/v1"
"go.chromium.org/luci/auth"
"go.chromium.org/luci/cipd/common"
"go.chromium.org/luci/common/logging"
"golang.org/x/oauth2"
"google.golang.org/protobuf/encoding/protojson"
structpb "google.golang.org/protobuf/types/known/structpb"
)
const (
ProdKeyID = "projects/fuchsia-bcid/locations/global/keyRings/bcid/cryptoKeys/attestation_signer/cryptoKeyVersions/1"
TestKeyID = "projects/fuchsia-bcid-dev/locations/global/keyRings/tests/cryptoKeys/recipe_wrapper_attestation_signer/cryptoKeyVersions/1"
StmtType = "https://in-toto.io/Statement/v1"
PredicateType = "https://slsa.dev/provenance/v1"
sciloEndpoint = "https://bcidsoftwareverifier-pa.googleapis.com/v1/software-artifact-verification-requests"
)
type attestOutputter struct{}
func (*attestOutputter) CombinedOutput(ctx context.Context, stdin []byte, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "attestation_tool", args...)
if stdin != nil {
pipe, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("unable to pipe to stdin: %v", err)
}
pipe.Write(stdin)
// This close must occur before CombinedOutput() is called otherwise the command will wait forever.
pipe.Close()
}
logging.Infof(ctx, "Executing %+v\n", cmd)
return cmd.CombinedOutput()
}
func CanAttestPlatform() bool {
return runtime.GOOS == "linux" && runtime.GOARCH == "amd64"
}
func Attest(ctx context.Context, stmt *spb.Statement, keyID string) (*InTotoBundle, error) {
if !CanAttestPlatform() {
return nil, fmt.Errorf("platform %v/%v is unsupported for attestation", runtime.GOOS, runtime.GOARCH)
}
return attestWithOutputter(ctx, &attestOutputter{}, stmt, keyID)
}
type CombinedOutputer interface {
CombinedOutput(context.Context, []byte, ...string) ([]byte, error)
}
func attestWithOutputter(ctx context.Context, cmd CombinedOutputer, stmt *spb.Statement, keyID string) (*InTotoBundle, error) {
stmtJSON, err := protojson.Marshal(stmt)
if err != nil {
return nil, err
}
out, err := cmd.CombinedOutput(ctx, stmtJSON, "sign_intoto_statement",
"--input", "-",
"--key_id", keyID,
"--output", "-")
if err != nil {
return nil, fmt.Errorf("attestation_tool failed: %v / %q", err, out)
}
return UnmarshalBundle(bytes.NewReader(out))
}
type Signature struct {
KeyID string `json:"keyid"`
Sig string `json:"sig"`
}
// InToToBundle represents the attestation bundle.
// TODO(https://github.com/in-toto/attestation/issues/280): This struct should ideally be provided upstream.
type InTotoBundle struct {
ContentType string `json:"payloadType"`
Payload string `json:"payload"`
Signatures []Signature `json:"signatures"`
}
func UnmarshalBundle(data io.Reader) (*InTotoBundle, error) {
all, err := io.ReadAll(data)
if err != nil {
return nil, fmt.Errorf("can't read input data: %v", err)
}
var itt InTotoBundle
if err := json.Unmarshal(all, &itt); err != nil {
return nil, fmt.Errorf("unable to pass in-toto payload: %v", err)
}
return &itt, nil
}
type Subject struct {
Pkg *common.Pin
Data io.Reader
}
func NewStmt(subject *Subject) (*spb.Statement, error) {
if subject == nil {
return nil, errors.New("subject cannot be nil")
}
h := sha256.New()
if _, err := io.Copy(h, subject.Data); err != nil {
return nil, err
}
// The statement proto uses this struct proto which is dynamically typed.
// It then uses reflection to try and figure out what it all is.
// The only real way to work through this is by using raw types, not
// real Go structs.
// It's possible to just dump raw JSON here, but that feels even less
// idomatic than this does.
// Unless tagged as required, fields are optional.
// Any changes to this specification should be reflected in the external
// documentation at //slsa/buildConfig/v1
// Do not remove fields once populated unless BCID For Software team is
// consulted.
pred, err := structpb.NewStruct(map[string]any{
// Required.
"buildDefinition": map[string]any{
// Required.
"buildType": "https://fuchsia.googlesource.com/infra/infra/slsa/buildConfig/v1",
// Required, but fields within may be best-effort.
// The spec is unclear whether this field can be included empty.
"externalParameters": map[string]any{
"buildConfigSource": map[string]any{
"repository": "git+https://fuchsia.googlesource.com/infra/recipes",
"path": "recipes.py",
},
},
},
// Required.
"runDetails": map[string]any{
// Required.
"builder": map[string]any{
// Required.
"id": "//bcid.corp.google.com/builders/luci/fuchsia/l1",
// TODO(cflewis): Add the version here.
},
},
})
if err != nil {
return nil, err
}
return &spb.Statement{
Type: StmtType,
Subject: []*spb.ResourceDescriptor{
{
Name: subject.Pkg.PackageName,
// cipd_instance_id is required by SCILo to understand what is being uploaded.
// This key isn't arbitrary chosen and _mustn't be changed_.
Digest: map[string]string{"cipd_instance_id": subject.Pkg.InstanceID},
},
},
PredicateType: PredicateType,
Predicate: pred,
}, nil
}
type sciloContext struct {
VerificationPurpose string `json:"verificationPurpose"`
EnforcementPointName string `json:"enforcementPointName"`
OccurrenceStage string `json:"occurrenceStage"`
}
type sciloArtifactInfo struct {
Digests map[string]string `json:"digests"`
// Attestations are _strings_ of InTotoBundles.
// Passing bundles as JSON is not accepted by the endpoint.
Attestations []string `json:"attestations"`
}
type sciloReq struct {
Context *sciloContext `json:"context"`
ResourceToVerify string `json:"resourceToVerify"`
ArtifactInfo *sciloArtifactInfo `json:"artifactInfo"`
}
type SCILoResp struct {
Allowed bool `json:"allowed"`
RejectionMessage string `json:"rejectionMessage"`
VerificationSummary string `json:"verificationSummary"`
}
// UploadToSCILo uploads the bundle to SCILo using default values.
func UploadToSCILo(ctx context.Context, stmt *spb.Statement, bundle *InTotoBundle) (*SCILoResp, error) {
ts, err := LUCITokenSource(ctx)
if err != nil {
return nil, err
}
return UploadToSCILoEndpoint(ctx, sciloEndpoint, ts, stmt, bundle)
}
func UploadToSCILoEndpoint(ctx context.Context,
endpoint string, tokenSource oauth2.TokenSource, stmt *spb.Statement, bundle *InTotoBundle) (*SCILoResp, error) {
token, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("couldn't get auth token: %v", err)
}
if subjs := len(stmt.GetSubject()); subjs != 1 {
return nil, fmt.Errorf("expected 1 subject in statement, got %v", subjs)
}
subj := stmt.GetSubject()[0]
jsonBundle, err := json.Marshal(bundle)
if err != nil {
return nil, fmt.Errorf("couldn't marshal bundle to JSON: %v", err)
}
sReq := &sciloReq{
// These values are documented at go/scilo-server#artifact-occurrence-stage
Context: &sciloContext{
VerificationPurpose: "VERIFY_FOR_ENFORCEMENT",
// This is an arbitrary name to identify what is attesting.
EnforcementPointName: "fuchsia-recipe-wrapper",
// This means that the attestation has been made after the build has completed
// and will no longer be modified.
OccurrenceStage: "AS_VERIFIED",
},
ResourceToVerify: "cipd_package://" + subj.GetName(),
ArtifactInfo: &sciloArtifactInfo{
Digests: subj.GetDigest(),
Attestations: []string{string(jsonBundle)},
},
}
jsonReq, err := json.Marshal(sReq)
if err != nil {
return nil, fmt.Errorf("couldn't marshal SCILo Request to JSON: %v", err)
}
logging.Infof(ctx, "Uploading attestations to SCILo:\n%s", jsonBundle)
httpReq, _ := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(jsonReq))
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json")
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %v", token.AccessToken))
// The dump needs to happen _before_ the httpclient.Do() call.
// Do() probably consumes the request and then dump is empty.
// DumpRequestOut() will copy back the data it consumes so the
// Do() call works correctly.
dump, err := httputil.DumpRequestOut(httpReq, true)
if err != nil {
return nil, fmt.Errorf("unable to dump http request: %v", err)
}
// TODO(cflewis): This might need to be retried if the BCID server is observed to flake out.
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("unable to contact BCID endpoint: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("couldn't read HTTP body: %v", err)
}
// Check if the request was blocked by lack of a good token.
// If so, give a more informative error.
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("GFE blocked access attempt, probably due to bad auth token. Output:\n%s", body)
}
// If there was no good status there really isn't anything meaningful to be done at
// this point. There's no recovery that can be taken.
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("bad HTTP status code %v: body:\n%s\noriginal request:\n%s", resp.StatusCode, body, dump)
}
var sResp SCILoResp
err = json.Unmarshal(body, &sResp)
if err != nil {
return nil, fmt.Errorf("unable to marshal HTTP body to SCILoResp: %v", err)
}
return &sResp, nil
}
func LUCITokenSource(ctx context.Context) (oauth2.TokenSource, error) {
authenticator := auth.NewAuthenticator(ctx, auth.SilentLogin, auth.Options{
Scopes: []string{"https://www.googleapis.com/auth/bcid_verify"},
})
return authenticator.TokenSource()
}