internal/gomote: add swarming sign SSH key

This change adds the SignSSHKey endpoint to the swarming
implementation of the gomote server.

Fixes golang/go#63790

Change-Id: I4997f6ec90b34fbe9efccfd13eb37dc5de5e66d5
Reviewed-on: https://go-review.googlesource.com/c/build/+/538279
Auto-Submit: Carlos Amedee <carlos@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
diff --git a/internal/gomote/swarming.go b/internal/gomote/swarming.go
index 59b9393..fc77c19 100644
--- a/internal/gomote/swarming.go
+++ b/internal/gomote/swarming.go
@@ -403,6 +403,28 @@
 	return &protos.RemoveFilesResponse{}, nil
 }
 
+// SignSSHKey signs the public SSH key with a certificate. The signed public SSH key is intended for use with the gomote service SSH
+// server. It will be signed by the certificate authority of the server and will restrict access to the gomote instance that it was
+// signed for.
+func (ss *SwarmingServer) SignSSHKey(ctx context.Context, req *protos.SignSSHKeyRequest) (*protos.SignSSHKeyResponse, error) {
+	creds, err := access.IAPFromContext(ctx)
+	if err != nil {
+		return nil, status.Errorf(codes.Unauthenticated, "request does not contain the required authentication")
+	}
+	session, err := ss.session(req.GetGomoteId(), creds.ID)
+	if err != nil {
+		// the helper function returns meaningful GRPC error.
+		return nil, err
+	}
+	signedPublicKey, err := remote.SignPublicSSHKey(ctx, ss.sshCertificateAuthority, req.GetPublicSshKey(), session.ID, session.OwnerID, 5*time.Minute)
+	if err != nil {
+		return nil, status.Errorf(codes.InvalidArgument, "unable to sign ssh key")
+	}
+	return &protos.SignSSHKeyResponse{
+		SignedPublicSshKey: signedPublicKey,
+	}, nil
+}
+
 // session is a helper function that retrieves a session associated with the gomoteID and ownerID.
 func (ss *SwarmingServer) session(gomoteID, ownerID string) (*remote.Session, error) {
 	session, err := ss.buildlets.Session(gomoteID)
diff --git a/internal/gomote/swarming_test.go b/internal/gomote/swarming_test.go
index d8025cc..bd155c8 100644
--- a/internal/gomote/swarming_test.go
+++ b/internal/gomote/swarming_test.go
@@ -665,6 +665,85 @@
 	}
 }
 
+func TestSwarmingSignSSHKey(t *testing.T) {
+	ctx := access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP())
+	client := setupGomoteSwarmingTest(t, context.Background(), mockSwarmClientSimple())
+	gomoteID := mustCreateSwarmingInstance(t, client, fakeIAP())
+	if _, err := client.SignSSHKey(ctx, &protos.SignSSHKeyRequest{
+		GomoteId:     gomoteID,
+		PublicSshKey: []byte(devCertCAPublic),
+	}); err != nil {
+		t.Fatalf("client.SignSSHKey(ctx, req) = response, %s; want no error", err)
+	}
+}
+
+func TestSwarmingSignSSHKeyError(t *testing.T) {
+	// This test will create a gomote instance and attempt to call SignSSHKey.
+	// If overrideID is set to true, the test will use a different gomoteID than
+	// the one created for the test.
+	testCases := []struct {
+		desc          string
+		ctx           context.Context
+		overrideID    bool
+		gomoteID      string // Used iff overrideID is true.
+		publickSSHKey []byte
+		wantCode      codes.Code
+	}{
+		{
+			desc:     "unauthenticated request",
+			ctx:      context.Background(),
+			wantCode: codes.Unauthenticated,
+		},
+		{
+			desc:       "missing gomote id",
+			ctx:        access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP()),
+			overrideID: true,
+			gomoteID:   "",
+			wantCode:   codes.NotFound,
+		},
+		{
+			desc:     "missing public key",
+			ctx:      access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAP()),
+			wantCode: codes.InvalidArgument,
+		},
+		{
+			desc:          "gomote does not exist",
+			ctx:           access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAPWithUser("foo", "bar")),
+			overrideID:    true,
+			gomoteID:      "chucky",
+			publickSSHKey: []byte(devCertCAPublic),
+			wantCode:      codes.NotFound,
+		},
+		{
+			desc:          "wrong gomote id",
+			ctx:           access.FakeContextWithOutgoingIAPAuth(context.Background(), fakeIAPWithUser("foo", "bar")),
+			overrideID:    false,
+			publickSSHKey: []byte(devCertCAPublic),
+			wantCode:      codes.PermissionDenied,
+		},
+	}
+	for _, tc := range testCases {
+		t.Run(tc.desc, func(t *testing.T) {
+			client := setupGomoteSwarmingTest(t, context.Background(), mockSwarmClientSimple())
+			gomoteID := mustCreateSwarmingInstance(t, client, fakeIAP())
+			if tc.overrideID {
+				gomoteID = tc.gomoteID
+			}
+			req := &protos.SignSSHKeyRequest{
+				GomoteId:     gomoteID,
+				PublicSshKey: tc.publickSSHKey,
+			}
+			got, err := client.SignSSHKey(tc.ctx, req)
+			if err != nil && status.Code(err) != tc.wantCode {
+				t.Fatalf("unexpected error: %s; want %s", err, tc.wantCode)
+			}
+			if err == nil {
+				t.Fatalf("client.SignSSHKey(ctx, %v) = %v, nil; want error", req, got)
+			}
+		})
+	}
+}
+
 func TestSwarmingRemoveFilesError(t *testing.T) {
 	// This test will create a gomote instance and attempt to call RemoveFiles.
 	// If overrideID is set to true, the test will use a different gomoteID than