Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

secure set manifest endpoint #136

Merged
merged 8 commits into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ layers_cache
layers-cache.json
mesh-root.pem
coordinator-root.pem
workload-owner.pem
justfile.env
workspace
workspace.cache
Expand Down
1 change: 1 addition & 0 deletions cli/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
const (
coordRootPEMFilename = "coordinator-root.pem"
coordIntermPEMFilename = "mesh-root.pem"
workloadOwnerPEM = "workload-owner.pem"
manifestFilename = "manifest.json"
settingsFilename = "settings.json"
rulesFilename = "rules.rego"
Expand Down
130 changes: 119 additions & 11 deletions cli/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@ package main
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"

"github.com/edgelesssys/nunki/internal/embedbin"
Expand Down Expand Up @@ -49,7 +55,12 @@ func newGenerateCmd() *cobra.Command {
cmd.Flags().StringP("policy", "p", policyDir, "path to policy (.rego) file")
cmd.Flags().StringP("settings", "s", settingsFilename, "path to settings (.json) file")
cmd.Flags().StringP("manifest", "m", manifestFilename, "path to manifest (.json) file")

cmd.Flags().StringArrayP("workload-owner-key", "w", []string{workloadOwnerPEM}, "path to workload owner key (.pem) file")
malt3 marked this conversation as resolved.
Show resolved Hide resolved
cmd.Flags().BoolP("disable-updates", "d", false, "prevent further updates of the manifest")
must(cmd.MarkFlagFilename("policy", "rego"))
must(cmd.MarkFlagFilename("settings", "json"))
must(cmd.MarkFlagFilename("manifest", "json"))
cmd.MarkFlagsMutuallyExclusive("workload-owner-key", "disable-updates")
return cmd
}

Expand Down Expand Up @@ -82,6 +93,10 @@ func runGenerate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to create policy map: %w", err)
}

if err := generateWorkloadOwnerKey(flags); err != nil {
return fmt.Errorf("generating workload owner key: %w", err)
}

defaultManifest := manifest.Default()
defaultManifestData, err := json.MarshalIndent(&defaultManifest, "", " ")
if err != nil {
Expand All @@ -96,6 +111,18 @@ func runGenerate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to unmarshal manifest: %w", err)
}
manifest.Policies = policyMap

if flags.disableUpdates {
manifest.WorkloadOwnerKeyDigests = nil
} else {
for _, keyPath := range flags.workloadOwnerKeys {
if err := addWorkloadOwnerKeyToManifest(manifest, keyPath); err != nil {
return fmt.Errorf("adding workload owner key to manifest: %w", err)
}
}
}
slices.Sort(manifest.WorkloadOwnerKeyDigests)

manifestData, err = json.MarshalIndent(manifest, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal manifest: %w", err)
Expand Down Expand Up @@ -162,10 +189,10 @@ func filterNonCoCoRuntime(runtimeClassName string, paths []string, logger *slog.
}

func generatePolicies(ctx context.Context, regoPath, policyPath string, yamlPaths []string, logger *slog.Logger) error {
if err := createFileWithDefault(filepath.Join(regoPath, policyPath), defaultGenpolicySettings); err != nil {
if err := createFileWithDefault(filepath.Join(regoPath, policyPath), func() ([]byte, error) { return defaultGenpolicySettings, nil }); err != nil {
return fmt.Errorf("creating default policy file: %w", err)
}
if err := createFileWithDefault(filepath.Join(regoPath, rulesFilename), defaultRules); err != nil {
if err := createFileWithDefault(filepath.Join(regoPath, rulesFilename), func() ([]byte, error) { return defaultRules, nil }); err != nil {
return fmt.Errorf("creating default policy.rego file: %w", err)
}
binaryInstallDir, err := installDir()
Expand Down Expand Up @@ -195,6 +222,43 @@ func generatePolicies(ctx context.Context, regoPath, policyPath string, yamlPath
return nil
}

func addWorkloadOwnerKeyToManifest(manifst *manifest.Manifest, keyPath string) error {
keyData, err := os.ReadFile(keyPath)
if err != nil {
return fmt.Errorf("reading workload owner key: %w", err)
}
block, _ := pem.Decode(keyData)
if block == nil {
return errors.New("failed to decode PEM block")
}
var publicKey []byte
switch block.Type {
case "PUBLIC KEY":
publicKey = block.Bytes
case "EC PRIVATE KEY":
privateKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return fmt.Errorf("parsing EC private key: %w", err)
}
publicKey, err = x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return fmt.Errorf("marshaling public key: %w", err)
}
default:
return fmt.Errorf("unsupported PEM block type: %s", block.Type)
}

hash := sha256.Sum256(publicKey)
hashString := manifest.NewHexString(hash[:])
for _, existingHash := range manifst.WorkloadOwnerKeyDigests {
if existingHash == hashString {
return nil
}
}
manifst.WorkloadOwnerKeyDigests = append(manifst.WorkloadOwnerKeyDigests, hashString)
return nil
}

func generatePolicyForFile(ctx context.Context, genpolicyPath, regoPath, policyPath, yamlPath string, logger *slog.Logger) ([32]byte, error) {
args := []string{
"--raw-out",
Expand Down Expand Up @@ -223,10 +287,39 @@ func generatePolicyForFile(ctx context.Context, genpolicyPath, regoPath, policyP
return policyHash, nil
}

func generateWorkloadOwnerKey(flags *generateFlags) error {
if flags.disableUpdates || len(flags.workloadOwnerKeys) != 1 {
malt3 marked this conversation as resolved.
Show resolved Hide resolved
// No need to generate keys
// either updates are disabled or
// the user has provided a set of (presumably already generated) public keys
return nil
}
keyPath := flags.workloadOwnerKeys[0]
malt3 marked this conversation as resolved.
Show resolved Hide resolved

if err := createFileWithDefault(keyPath, newKeyPair); err != nil {
return fmt.Errorf("creating default workload owner key file: %w", err)
}
return nil
}

func newKeyPair() ([]byte, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generating private key: %w", err)
}
privateKeyBytes, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("marshaling private key: %w", err)
}
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privateKeyBytes}), nil
}

type generateFlags struct {
policyPath string
settingsPath string
manifestPath string
policyPath string
settingsPath string
manifestPath string
workloadOwnerKeys []string
disableUpdates bool
}

func parseGenerateFlags(cmd *cobra.Command) (*generateFlags, error) {
Expand All @@ -242,10 +335,21 @@ func parseGenerateFlags(cmd *cobra.Command) (*generateFlags, error) {
if err != nil {
return nil, err
}
workloadOwnerKeys, err := cmd.Flags().GetStringArray("workload-owner-key")
if err != nil {
return nil, err
}
disableUpdates, err := cmd.Flags().GetBool("disable-updates")
if err != nil {
return nil, err
}

return &generateFlags{
policyPath: policyPath,
settingsPath: settingsPath,
manifestPath: manifestPath,
policyPath: policyPath,
settingsPath: settingsPath,
manifestPath: manifestPath,
workloadOwnerKeys: workloadOwnerKeys,
disableUpdates: disableUpdates,
}, nil
}

Expand All @@ -264,7 +368,7 @@ func readFileOrDefault(path string, deflt []byte) ([]byte, error) {

// createFileWithDefault creates the file at path with the default value,
// if it doesn't exist.
func createFileWithDefault(path string, deflt []byte) error {
func createFileWithDefault(path string, dflt func() ([]byte, error)) error {
malt3 marked this conversation as resolved.
Show resolved Hide resolved
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if os.IsExist(err) {
return nil
Expand All @@ -273,7 +377,11 @@ func createFileWithDefault(path string, deflt []byte) error {
return err
}
defer file.Close()
_, err = file.Write(deflt)
content, err := dflt()
if err != nil {
return err
}
_, err = file.Write(content)
return err
}

Expand Down
83 changes: 79 additions & 4 deletions cli/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ package main

import (
"context"
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net"
"os"
"slices"
"time"

"github.com/edgelesssys/nunki/internal/atls"
Expand All @@ -18,6 +24,8 @@ import (
"github.com/edgelesssys/nunki/internal/manifest"
"github.com/edgelesssys/nunki/internal/spinner"
"github.com/spf13/cobra"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

func newSetCmd() *cobra.Command {
Expand All @@ -43,6 +51,7 @@ func newSetCmd() *cobra.Command {
cmd.Flags().StringP("coordinator", "c", "", "endpoint the coordinator can be reached at")
must(cobra.MarkFlagRequired(cmd.Flags(), "coordinator"))
cmd.Flags().String("coordinator-policy-hash", DefaultCoordinatorPolicyHash, "expected policy hash of the coordinator, will not be checked if empty")
cmd.Flags().String("workload-owner-key", workloadOwnerPEM, "path to workload owner key (.pem) file")

return cmd
}
Expand All @@ -67,6 +76,11 @@ func runSet(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to unmarshal manifest: %w", err)
}

workloadOwnerKey, err := loadWorkloadOwnerKey(flags.workloadOwnerKeyPath, m, log)
if err != nil {
return fmt.Errorf("loading workload owner key: %w", err)
}

paths, err := findGenerateTargets(args, log)
if err != nil {
return fmt.Errorf("finding yaml files: %w", err)
Expand All @@ -90,7 +104,7 @@ func runSet(cmd *cobra.Command, args []string) error {
kdsCache := fsstore.New(kdsDir, log.WithGroup("kds-cache"))
kdsGetter := snp.NewCachedHTTPSGetter(kdsCache, snp.NeverGCTicker, log.WithGroup("kds-getter"))
validator := snp.NewValidator(validateOptsGen, kdsGetter, log.WithGroup("snp-validator"))
dialer := dialer.New(atls.NoIssuer, validator, &net.Dialer{})
dialer := dialer.NewWithKey(atls.NoIssuer, validator, &net.Dialer{}, workloadOwnerKey)

conn, err := dialer.Dial(cmd.Context(), flags.coordinator)
if err != nil {
Expand All @@ -105,6 +119,18 @@ func runSet(cmd *cobra.Command, args []string) error {
}
resp, err := setLoop(cmd.Context(), client, cmd.OutOrStdout(), req)
if err != nil {
grpcSt, ok := status.FromError(err)
if ok {
if grpcSt.Code() == codes.PermissionDenied {
msg := "Permission denied."
if workloadOwnerKey == nil {
msg += " Specify a workload owner key with --workload-owner-key."
} else {
msg += " Ensure you are using a trusted workload owner key."
}
fmt.Fprintln(cmd.OutOrStdout(), msg)
}
}
return fmt.Errorf("failed to set manifest: %w", err)
}

Expand All @@ -122,9 +148,10 @@ func runSet(cmd *cobra.Command, args []string) error {
}

type setFlags struct {
manifestPath string
coordinator string
policy []byte
manifestPath string
coordinator string
policy []byte
workloadOwnerKeyPath string
}

func parseSetFlags(cmd *cobra.Command) (*setFlags, error) {
Expand All @@ -147,6 +174,10 @@ func parseSetFlags(cmd *cobra.Command) (*setFlags, error) {
if err != nil {
return nil, fmt.Errorf("hex-decoding coordinator-policy-hash flag: %w", err)
}
flags.workloadOwnerKeyPath, err = cmd.Flags().GetString("workload-owner-key")
if err != nil {
return nil, fmt.Errorf("getting workload-owner-key flag: %w", err)
}

return flags, nil
}
Expand All @@ -159,6 +190,42 @@ func policyMapToBytesList(m map[string]deployment) [][]byte {
return policies
}

func loadWorkloadOwnerKey(path string, manifst manifest.Manifest, log *slog.Logger) (*ecdsa.PrivateKey, error) {
key, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, nil
malt3 marked this conversation as resolved.
Show resolved Hide resolved
}
if err != nil {
return nil, fmt.Errorf("reading workload owner key: %w", err)
}
pemBlock, _ := pem.Decode(key)
if pemBlock == nil {
return nil, fmt.Errorf("decoding workload owner key: %w", err)
}
if pemBlock.Type != "EC PRIVATE KEY" {
return nil, fmt.Errorf("workload owner key is not an EC private key")
}
workloadOwnerKey, err := x509.ParseECPrivateKey(pemBlock.Bytes)
if err != nil {
return nil, fmt.Errorf("parsing workload owner key: %w", err)
}
pubKey, err := x509.MarshalPKIXPublicKey(&workloadOwnerKey.PublicKey)
if err != nil {
return nil, fmt.Errorf("marshaling public key: %w", err)
}
ownerKeyHash := sha256.Sum256(pubKey)
ownerKeyHex := manifest.NewHexString(ownerKeyHash[:])
if len(manifst.WorkloadOwnerKeyDigests) == 0 {
log.Warn("No workload owner keys in manifest. Further manifest updates will be rejected by the coordinator")
return workloadOwnerKey, nil
}
log.Debug("Workload owner keys in manifest", "keys", manifst.WorkloadOwnerKeyDigests)
if !slices.Contains(manifst.WorkloadOwnerKeyDigests, ownerKeyHex) {
log.Warn("Workload owner key not found in manifest. This may lock you out from further updates")
}
return workloadOwnerKey, nil
}

func setLoop(
ctx context.Context, client coordapi.CoordAPIClient, out io.Writer, req *coordapi.SetManifestRequest,
) (resp *coordapi.SetManifestResponse, retErr error) {
Expand All @@ -178,6 +245,14 @@ func setLoop(
if rpcErr == nil {
return resp, nil
}
grpcSt, ok := status.FromError(rpcErr)
if ok {
switch grpcSt.Code() {
case codes.PermissionDenied, codes.InvalidArgument:
// These errors are not retryable
return nil, rpcErr
}
}
timer := time.NewTimer(1 * time.Second)
select {
case <-ctx.Done():
Expand Down
Loading
Loading