[rw] Call BCID endpoint

This change adds functionality to call the BCID endpoint and read the
response. This is essentially impossible to test locally, the
allowlists/scopes/token exchange hoops are too numerous and brittle. The
unit test just makes sure that a reasonable request is able to be
unmarshaled into a reasonable response.

Unfortunately this is going to have to be a test in production thing.

Bug: b/299941280

Change-Id: I5e9adde2203a1fbcbd55a03b12503b14b40d5067
Reviewed-on: https://fuchsia-review.googlesource.com/c/infra/infra/+/919593
Reviewed-by: Nathan Mulcahey <nmulcahey@google.com>
Commit-Queue: Chris Lewis <cflewis@google.com>
diff --git a/cmd/recipe_wrapper/bcid/bcid.go b/cmd/recipe_wrapper/bcid/bcid.go
index 4869f9e..3e1dd7a 100644
--- a/cmd/recipe_wrapper/bcid/bcid.go
+++ b/cmd/recipe_wrapper/bcid/bcid.go
@@ -9,8 +9,10 @@
 	"context"
 	"crypto/sha256"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
+	"net/http"
 	"os/exec"
 	"time"
 
@@ -106,6 +108,9 @@
 }
 
 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
@@ -156,6 +161,87 @@
 	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"`
+}
+
+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"},
diff --git a/cmd/recipe_wrapper/bcid/bcid_test.go b/cmd/recipe_wrapper/bcid/bcid_test.go
index 6c3a22c..9cba98f 100644
--- a/cmd/recipe_wrapper/bcid/bcid_test.go
+++ b/cmd/recipe_wrapper/bcid/bcid_test.go
@@ -9,9 +9,11 @@
 	"archive/zip"
 	"bytes"
 	"context"
+	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
+	"net/http/httptest"
 	"os"
 	"os/exec"
 	"strings"
@@ -34,8 +36,9 @@
 	return ctx
 }
 
-// This is read from a test data file because the line length is enormous.
+// These are read from a test data file because the line lengths are enormous.
 var fakeInTotoOutput []byte
+var fakeVerificationResponse []byte
 
 type testAttestCommander struct{}
 
@@ -47,7 +50,7 @@
 }
 
 // commander chooses a real version of a tool if it is available and the test
-// isn't running in t.Short. Otherwise, returns a stubbed outputer.
+// isn't running in t.Short. Otherwise, returns a stubbed outputter.
 func commander(t *testing.T, ctx context.Context, tool string, args ...string) CombinedOutputer {
 	t.Helper()
 
@@ -197,6 +200,75 @@
 	}
 }
 
+func TestUploadBundle(t *testing.T) {
+	// It's simply too difficult to test hitting the BCID API
+	// given the allowlists, and OAuth token exchanges (with private scopes) required.
+	// Bring up a test server and just check that the requests are reasonably
+	// formatted.
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			http.Error(w, fmt.Sprintf("can't read body: %v", err), http.StatusBadRequest)
+		}
+		defer r.Body.Close()
+
+		var req sciloReq
+		if err := json.Unmarshal(body, &req); err != nil {
+			http.Error(w, fmt.Sprintf("can't unmarshal request to valid SCILo request: %v\nBody: %s", err, body), http.StatusBadRequest)
+		}
+
+		fmt.Fprintln(w, string(fakeVerificationResponse))
+	}))
+	defer ts.Close()
+
+	fakeBundle, err := UnmarshalBundle(bytes.NewReader(fakeInTotoOutput))
+	if err != nil {
+		t.Fatalf("couldn't marshal fake bundle: %v", err)
+	}
+
+	tests := []struct {
+		name         string
+		subject      *Subject
+		bundle       *InTotoBundle
+		wantAccepted bool
+	}{
+		{
+			name: "A good statement and bundle should produce a good response",
+			subject: &Subject{
+				Name: "foopkg",
+				Data: strings.NewReader("bardata"),
+			},
+			bundle:       fakeBundle,
+			wantAccepted: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			stmt, err := NewStmt(test.subject)
+			if err != nil {
+				t.Fatalf("couldn't create statement from subject: %v", err)
+			}
+			resp, err := UploadBundleToEndpoint(
+				context.Background(),
+				ts.URL,
+				fakeLUCIToken,
+				stmt,
+				test.bundle,
+			)
+			if err != nil {
+				t.Errorf("got error %v, want nil error", err)
+			}
+			if resp == nil {
+				t.Error("got nil response, want valid response")
+			}
+			if test.wantAccepted && !(resp.Allowed && resp.RejectionMessage == "") {
+				t.Errorf("wanted accepted response with no rejection message, got %+v", resp)
+			}
+		})
+	}
+}
+
 func mustRead(filename string) []byte {
 	f, err := os.Open(filename)
 	if err != nil {
@@ -213,6 +285,7 @@
 
 func TestMain(m *testing.M) {
 	fakeInTotoOutput = mustRead("testdata/in-toto-output.json")
+	fakeVerificationResponse = mustRead("testdata/verification-response.json")
 
 	os.Exit(m.Run())
 }
diff --git a/cmd/recipe_wrapper/bcid/testdata/verification-response.json b/cmd/recipe_wrapper/bcid/testdata/verification-response.json
new file mode 100644
index 0000000..2801042
--- /dev/null
+++ b/cmd/recipe_wrapper/bcid/testdata/verification-response.json
@@ -0,0 +1,4 @@
+{
+  "allowed": true,
+  "verificationSummary": "{\"payload\":\"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9zbHNhLmRldi92ZXJpZmljYXRpb25fc3VtbWFyeS92MC4yIiwicHJlZGljYXRlIjp7InBvbGljeSI6eyJ1cmkiOiJnb29nbGVmaWxlOi9nb29nbGVfc3JjL2ZpbGVzLzU2NzM5NTQ4MS9kZXBvdC9nb29nbGUzL3Byb2R1Y3Rpb24vc2VjdXJpdHkvYmNpZC9zb2Z0d2FyZS9taXNjX3NvZnR3YXJlL2RhcnQvc2RrLnN3X3BvbGljeS50ZXh0cHJvdG8ifSwidGltZV92ZXJpZmllZCI6IjIwMjMtMDktMjFUMjA6Mzg6NDEuNDM2NDY1WiIsInJlc291cmNlX3VyaSI6Im1pc2Nfc29mdHdhcmU6Ly9kYXJ0L3Nkay9tYWNvcyIsInBvbGljeV9sZXZlbCI6IlNMU0FfTEVWRUxfMiIsInZlcmlmaWVyIjp7ImlkIjoiaHR0cHM6Ly9iY2lkLmNvcnAuZ29vZ2xlLmNvbS92ZXJpZmllci9iY2lkX3BhY2thZ2VfZW5mb3JjZXIvdjAuMSJ9LCJ2ZXJpZmljYXRpb25fcmVzdWx0IjoiUEFTU0VEIn0sInN1YmplY3QiOlt7Im5hbWUiOiJfIiwiZGlnZXN0Ijp7InNoYTI1NiI6IjRkNzAwYmZlMTVhYTM2NGZiMDYzMTUzNDRmODA2NjY4MzE0NDVlZTlkNDMzZDkxMDJiOTMyNjJiZWU5YTMxNWMifX1dfQ==\",\"payloadType\":\"application/vnd.in-toto+json\",\"signatures\":[{\"sig\":\"Pvs6oNhpUKSW/B+tgeqaM2lJBmlFkeuDvuegf5GGaBTJb0Uy4Ote7uaY4CG2YA45C5Mg9DDlURO+RiE54yfslw==\",\"keyid\":\"keystore://76574:prod:vsa_signing_public_key_staging\"}]}"
+}
\ No newline at end of file