peridot/peridot/keykeeper/v1/sign.go

436 lines
14 KiB
Go

// Copyright (c) All respective contributors to the Peridot Project. All rights reserved.
// Copyright (c) 2021-2022 Rocky Enterprise Software Foundation, Inc. All rights reserved.
// Copyright (c) 2021-2022 Ctrl IQ, Inc. All rights reserved.
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its contributors
// may be used to endorse or promote products derived from this software without
// specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
package keykeeperv1
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"github.com/google/uuid"
"go.temporal.io/sdk/activity"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/workflow"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/anypb"
"io"
"io/ioutil"
"os"
"os/exec"
"path/filepath"
peridotworkflow "peridot.resf.org/peridot/builder/v1/workflow"
"peridot.resf.org/peridot/db/models"
keykeeperpb "peridot.resf.org/peridot/keykeeper/pb"
peridotpb "peridot.resf.org/peridot/pb"
"peridot.resf.org/utils"
"strings"
"time"
)
var (
ErrUnsupportedExtension = errors.New("unsupported extension")
)
func (s *Server) SignArtifactsWorkflow(ctx workflow.Context, artifacts models.TaskArtifacts, buildId string, task *models.Task, keyName string) (*keykeeperpb.SignArtifactsTask, error) {
taskResponse := &keykeeperpb.SignArtifactsTask{
SignedArtifacts: []*keykeeperpb.SignedArtifact{},
}
err := s.db.SetTaskStatus(task.ID.String(), peridotpb.TaskStatus_TASK_STATUS_RUNNING)
if err != nil {
return nil, err
}
task.Status = peridotpb.TaskStatus_TASK_STATUS_FAILED
defer func() {
if taskResponse != nil {
taskResponseAny, err := anypb.New(taskResponse)
if err != nil {
s.log.Errorf("could not create anypb for task: %v", err)
task.Status = peridotpb.TaskStatus_TASK_STATUS_FAILED
} else {
err = s.db.SetTaskResponse(task.ID.String(), taskResponseAny)
if err != nil {
s.log.Errorf("could not set task info: %v", err)
task.Status = peridotpb.TaskStatus_TASK_STATUS_FAILED
}
}
}
err := s.db.SetTaskStatus(task.ID.String(), task.Status)
if err != nil {
s.log.Errorf("could not set task status: %v", err)
}
}()
if artifacts == nil {
var err error
artifacts, err = s.db.GetArtifactsForBuild(buildId)
if err != nil {
s.log.Errorf("failed to get artifacts for build %s: %v", buildId, err)
return nil, status.Error(codes.Internal, "failed to get artifacts")
}
if len(artifacts) == 0 {
return taskResponse, nil
}
}
var futures []peridotworkflow.FutureContext
for _, artifact := range artifacts {
signArtifactCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
ScheduleToStartTimeout: 10 * time.Hour,
StartToCloseTimeout: 24 * time.Hour,
HeartbeatTimeout: 10 * time.Minute,
TaskQueue: TaskQueue,
})
futures = append(futures, peridotworkflow.FutureContext{
Ctx: signArtifactCtx,
Future: workflow.ExecuteActivity(signArtifactCtx, s.SignArtifactActivity, artifact.ID.String(), keyName),
TaskQueue: TaskQueue,
})
}
for _, future := range futures {
signedArtifact := &keykeeperpb.SignedArtifact{}
err := future.Future.Get(future.Ctx, signedArtifact)
if err != nil && !strings.Contains(err.Error(), "unsupported extension") {
s.log.Errorf("could not get sign artifact: %v", err)
return nil, err
}
if signedArtifact != nil {
taskResponse.SignedArtifacts = append(taskResponse.SignedArtifacts, signedArtifact)
}
}
task.Status = peridotpb.TaskStatus_TASK_STATUS_SUCCEEDED
return taskResponse, nil
}
func (s *Server) SignArtifactActivity(ctx context.Context, artifactId string, keyName string) (*keykeeperpb.SignedArtifact, error) {
go func() {
for {
activity.RecordHeartbeat(ctx)
time.Sleep(4 * time.Second)
}
}()
artifact, err := s.db.GetTaskArtifactById(artifactId)
if err != nil {
s.log.Errorf("could not get artifact: %v", err)
return nil, status.Errorf(codes.Internal, "could not get artifact")
}
key, err := s.EnsureGPGKey(keyName)
if err != nil {
s.log.Errorf("failed to load key %s: %v", keyName, err)
return nil, status.Error(codes.Internal, "failed to load key")
}
newObjectKey := fmt.Sprintf("%s/%s/%s", filepath.Dir(artifact.Name), key.gpgId, filepath.Base(artifact.Name))
existingHash, err := s.db.GetTaskArtifactSignatureHash(artifact.Name, key.keyUuid.String())
if err != nil && err != sql.ErrNoRows {
s.log.Errorf("failed to get existing hash: %v", err)
return nil, status.Error(codes.Internal, "failed to get existing hash")
}
if err == nil && existingHash != "" {
return &keykeeperpb.SignedArtifact{
Path: newObjectKey,
HashSha256: existingHash,
}, nil
}
ranUuid := uuid.New()
localPath := fmt.Sprintf("/keykeeper/artifacts/%s-%s", ranUuid.String(), filepath.Base(artifact.Name))
err = s.storage.DownloadObject(artifact.Name, localPath)
if err != nil {
s.log.Errorf("failed to download artifact %s: %v", artifact.Name, err)
return nil, fmt.Errorf("failed to download artifact %s: %v", artifact.Name, err)
}
defer func() {
_ = os.Remove(localPath)
}()
ext := filepath.Ext(artifact.Name)
switch ext {
case ".rpm":
beginTx, err := s.db.Begin()
if err != nil {
s.log.Errorf("failed to begin transaction: %v", err)
return nil, status.Error(codes.Internal, "failed to begin transaction")
}
tx := s.db.UseTransaction(beginTx)
rpmSign := func() (*keykeeperpb.SignedArtifact, error) {
var outBuf bytes.Buffer
opts := []string{
"--define", "_gpg_name " + keyName,
"--define", "_peridot_keykeeper_key " + key.keyUuid.String(),
"--addsign", localPath,
}
cmd := gpgCmdEnv(exec.Command("rpm", opts...))
cmd.Stdout = &outBuf
cmd.Stderr = &outBuf
err := cmd.Run()
if err != nil {
s.log.Errorf("failed to sign artifact %s: %v", artifact.Name, err)
statusErr := status.New(codes.Internal, "failed to sign artifact")
statusErr, err2 := statusErr.WithDetails(&errdetails.ErrorInfo{
Reason: "rpmsign-failed",
Domain: "keykeeper.peridot.resf.org",
Metadata: map[string]string{
"logs": outBuf.String(),
"err": err.Error(),
},
})
if err2 != nil {
s.log.Errorf("failed to add error details to status: %v", err2)
}
return nil, statusErr.Err()
}
_, err = s.storage.PutObject(newObjectKey, localPath)
if err != nil {
s.log.Errorf("failed to upload artifact %s: %v", newObjectKey, err)
return nil, fmt.Errorf("failed to upload artifact %s: %v", newObjectKey, err)
}
f, err := os.Open(localPath)
if err != nil {
return nil, err
}
hasher := sha256.New()
_, err = io.Copy(hasher, f)
if err != nil {
return nil, err
}
hash := hex.EncodeToString(hasher.Sum(nil))
err = tx.CreateTaskArtifactSignature(artifact.ID.String(), key.keyUuid.String(), hash)
if err != nil {
s.log.Errorf("failed to create task artifact signature: %v", err)
return nil, fmt.Errorf("failed to create task artifact signature: %v", err)
}
return &keykeeperpb.SignedArtifact{
Path: newObjectKey,
HashSha256: hash,
}, nil
}
verifySig := func() error {
var outBuf bytes.Buffer
opts := []string{
"--define", "_gpg_name " + keyName,
"--define", "_peridot_keykeeper_key " + key.keyUuid.String(),
"--checksig", localPath,
}
cmd := gpgCmdEnv(exec.Command("rpm", opts...))
cmd.Stdout = &outBuf
cmd.Stderr = &outBuf
err := cmd.Run()
if err != nil {
s.log.Errorf("failed to verify artifact %s: %v", artifact.Name, err)
s.log.Errorf("buf: %s", outBuf.String())
return fmt.Errorf("failed to verify artifact %s: %v", artifact.Name, err)
}
return nil
}
res, err := rpmSign()
if err != nil {
_ = beginTx.Rollback()
return nil, err
}
err = verifySig()
if err != nil {
_ = beginTx.Rollback()
return nil, err
}
err = beginTx.Commit()
if err != nil {
s.log.Errorf("failed to commit transaction: %v", err)
return nil, status.Error(codes.Internal, "failed to commit transaction")
}
return res, nil
default:
s.log.Infof("skipping artifact %s, extension %s not supported", artifact.Name, ext)
return nil, ErrUnsupportedExtension
}
}
// SignArtifacts signs artifacts belonging to a build with the given key.
// The artifacts are loaded from the shared Peridot bucket and is
// then uploaded back.
// Since an artifact can have multiple signers, the resulting
// artifacts are uploaded to `{taskId}/{keyId}/{artifactName}`.
// Essentially we only add the keyId to the artifact key.
// Each artifact should in theory only be signed once,
// but signing it multiple times does not cause any harm.
// We currently fetch the artifact from the shared bucket,
// then sign it within the current container, then upload it back.
// todo(mustafa): Look into a way to avoid fetching the artifact from the shared bucket.
// todo(mustafa): We should still probably avoid signing the same
// todo(mustafa): artifact (with a key it's already signed with)
func (s *Server) SignArtifacts(_ context.Context, req *keykeeperpb.SignArtifactsRequest) (*peridotpb.AsyncTask, error) {
build, err := s.db.GetTaskByBuildId(req.BuildId)
if err != nil {
if err == sql.ErrNoRows {
return nil, status.Error(codes.NotFound, "build not found")
}
s.log.Errorf("failed to get build %s: %v", req.BuildId, err)
return nil, status.Error(codes.Internal, "failed to get build")
}
artifacts, err := s.db.GetArtifactsForBuild(req.BuildId)
if err != nil {
s.log.Errorf("failed to get artifacts for build %s: %v", req.BuildId, err)
return nil, status.Error(codes.Internal, "failed to get artifacts")
}
if len(artifacts) == 0 {
return nil, status.Error(codes.InvalidArgument, "no artifacts to sign")
}
rollback := true
beginTx, err := s.db.Begin()
if err != nil {
s.log.Error(err)
return nil, utils.InternalError
}
defer func() {
if rollback {
_ = beginTx.Rollback()
}
}()
tx := s.db.UseTransaction(beginTx)
buildTaskId := build.ID.String()
task, err := tx.CreateTask(nil, "noarch", peridotpb.TaskType_TASK_TYPE_KEYKEEPER_SIGN_ARTIFACT, &build.ProjectId.String, &buildTaskId)
if err != nil {
s.log.Errorf("could not create build task in SubmitBuild: %v", err)
return nil, status.Error(codes.InvalidArgument, "could not create import task")
}
taskProto, err := task.ToProto(false)
if err != nil {
return nil, status.Errorf(codes.Internal, "could not marshal task: %v", err)
}
rollback = false
err = beginTx.Commit()
if err != nil {
return nil, status.Error(codes.Internal, "could not save, try again")
}
_, err = s.temporal.ExecuteWorkflow(
context.Background(),
client.StartWorkflowOptions{
ID: task.ID.String(),
TaskQueue: TaskQueue,
},
s.SignArtifactsWorkflow,
nil,
req.BuildId,
task,
req.KeyName,
)
if err != nil {
return nil, status.Errorf(codes.Internal, "could not start workflow: %v", err)
}
return &peridotpb.AsyncTask{
TaskId: task.ID.String(),
Subtasks: []*peridotpb.Subtask{taskProto},
Done: false,
}, nil
}
// SignText signs given text with the given key.
// This method only returns the signature part of the gpg clearsign
func (s *Server) SignText(_ context.Context, req *keykeeperpb.SignTextRequest) (*keykeeperpb.SignTextResponse, error) {
key, err := s.EnsureGPGKey(req.KeyName)
if err != nil {
s.log.Errorf("failed to load key %s: %v", req.KeyName, err)
return nil, status.Error(codes.Internal, "failed to load key")
}
tmpFile, err := os.CreateTemp("", "")
if err != nil {
s.log.Errorf("failed to create temp file: %v", err)
return nil, status.Error(codes.Internal, "failed to create temp file")
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
err = ioutil.WriteFile(tmpFile.Name(), []byte(req.Text), 0644)
if err != nil {
s.log.Errorf("failed to write to temp file: %v", err)
return nil, status.Error(codes.Internal, "failed to write to temp file")
}
cmdArgs := []string{
"--batch",
"--no-verbose",
"--armor",
"--pinentry-mode=loopback",
"--no-secmem-warning",
"--detach-sign",
"--passphrase",
key.keyUuid.String(),
"-u",
req.KeyName,
"-o",
tmpFile.Name() + ".asc",
tmpFile.Name(),
}
cmd := gpgCmdEnv(exec.Command("gpg", cmdArgs...))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
s.log.Errorf("failed to sign text: %v", err)
return nil, status.Error(codes.Internal, "failed to sign text")
}
defer os.Remove(tmpFile.Name() + ".asc")
signedText, err := ioutil.ReadFile(tmpFile.Name() + ".asc")
if err != nil {
s.log.Errorf("failed to read signed text: %v", err)
return nil, status.Error(codes.Internal, "failed to read signed text")
}
return &keykeeperpb.SignTextResponse{
Signature: string(signedText),
}, nil
}