blob: fe26feecf77545fa215f398c2e06c9aa6079cfbd [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"
"os/exec"
"time"
spb "github.com/in-toto/attestation/go/v1"
"go.chromium.org/luci/auth"
"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"
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"
// TODO(b/299941280): Upload to prod when confident attestations are being generated correctly.
sciloEndpoint = "https://staging-bcidsoftwareverifier-pa.sandbox.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 Attest(ctx context.Context, stmt *spb.Statement, keyID string) (*InTotoBundle, error) {
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 {
Name string
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.
pred, err := structpb.NewStruct(map[string]any{
// TODO(cflewis): This definition will probably require more fields.
// Obvious candidates: `resolvedDependencies`, `internalParameters`.
"buildDefinition": map[string]any{
"externalParameters": map[string]any{
"buildConfigSource": map[string]any{
"repository": "git+https://fuchsia.googlesource.com/infra/recipes",
"path": "recipes.py",
},
},
},
"runDetails": map[string]any{
"builder": map[string]any{
"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.Name,
Digest: map[string]string{"sha256": fmt.Sprintf("%x", h.Sum(nil))},
},
},
PredicateType: PredicateType,
Predicate: pred,
}, nil
}
type tokener interface {
GetAccessToken(time.Duration) (*oauth2.Token, error)
}
type sciloContext struct {
VerificationPurpose string `json:"verificationPurpose"`
EnforcementPointName string `json:"enforcementPointName"`
OccurrenceStage string `json:"occurrenceStage"`
}
type sciloReq struct {
Context *sciloContext `json:"context"`
ResourceToVerify string `json:"resourceToVerify"`
ArtifactInfo *spb.ResourceDescriptor `json:"artifactInfo"`
Attestations []*InTotoBundle `json:"attestations"`
}
type SCILoResp struct {
Allowed bool `json:"allowed"`
RejectionMessage string `json:"rejectionMessage"`
VerificationSummary string `json:"verificationSummary"`
}
// UploadBundle uploads the bundle to BCID using default values.
func UploadBundle(ctx context.Context, stmt *spb.Statement, bundle *InTotoBundle) (*SCILoResp, error) {
token, err := LUCIToken(ctx)
if err != nil {
return nil, err
}
return UploadBundleToEndpoint(ctx, sciloEndpoint, token, stmt, bundle)
}
func UploadBundleToEndpoint(ctx context.Context,
endpoint string, oAuthToken string, stmt *spb.Statement, bundle *InTotoBundle) (*SCILoResp, error) {
if subjs := len(stmt.GetSubject()); subjs != 1 {
return nil, fmt.Errorf("expected 1 subject in statement, got %v", subjs)
}
subj := stmt.GetSubject()[0]
sReq := &sciloReq{
// These values are documented at go/scilo-server#artifact-occurrence-stage
Context: &sciloContext{
// TODO(b/295932544): Update this to VERIFY_FOR_ENFORCEMENT when ready.
// VERIFY_FOR_LOGGING is test purposes only while we are still ensuring this code
// attests correctly.
VerificationPurpose: "VERIFY_FOR_LOGGING",
// 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: subj,
Attestations: []*InTotoBundle{bundle},
}
jsonReq, err := json.Marshal(sReq)
if err != nil {
return nil, fmt.Errorf("couldn't marshal SCILo Request to JSON: %v", err)
}
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", oAuthToken))
// 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 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", resp.StatusCode, body)
}
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 LUCIToken(ctx context.Context) (string, error) {
authenticator := auth.NewAuthenticator(ctx, auth.SilentLogin, auth.Options{
Scopes: []string{"https://www.googleapis.com/auth/bcid_verify"},
})
return luciTokenWithAuthenticator(ctx, authenticator)
}
func luciTokenWithAuthenticator(ctx context.Context, t tokener) (string, error) {
// The maximum time allowed is 30 minutes.
token, err := t.GetAccessToken(30 * time.Minute)
if err != nil {
if err == auth.ErrLoginRequired {
return "", fmt.Errorf("login is required, but not possible headless: %q", err)
}
return "", fmt.Errorf("no valid token returned: %v", err)
}
if token.AccessToken == "" {
return "", fmt.Errorf("no valid token returned: %v", err)
}
return token.AccessToken, nil
}