[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