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

Derive and pass workload secrets to initializer #788

Merged
merged 9 commits into from
Aug 19, 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
4 changes: 2 additions & 2 deletions .github/workflows/e2e_kubernetes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
test_matrix:
strategy:
matrix:
test_name: [servicemesh, openssl, policy]
test_name: [servicemesh, openssl, policy, workloadsecret]
3u13r marked this conversation as resolved.
Show resolved Hide resolved

runs-on: ubuntu-22.04
permissions:
Expand Down Expand Up @@ -57,7 +57,7 @@ jobs:
- name: Get credentials for CI cluster
run: |
just get-credentials
- name: Set sync environemnt
- name: Set sync environment
run: |
sync_ip=$(kubectl get svc sync -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo "SYNC_ENDPOINT=http://$sync_ip:8080" | tee -a "$GITHUB_ENV"
Expand Down
9 changes: 9 additions & 0 deletions coordinator/internal/authority/authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ func (m *Authority) walkTransitions(transitionRef [history.HashSize]byte, consum
return nil
}

// GetSeedEngine returns the seed engine.
func (m *Authority) GetSeedEngine() (*seedengine.SeedEngine, error) {
se := m.se.Load()
if se == nil {
return nil, errors.New("seed engine not initialized")
}
return se, nil
}

// State is a snapshot of the Coordinator's manifest history.
type State struct {
Manifest *manifest.Manifest
Expand Down
4 changes: 3 additions & 1 deletion coordinator/internal/authority/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ func (c *Credentials) ServerHandshake(rawConn net.Conn) (net.Conn, credentials.A
return nil, nil, fmt.Errorf("getting state: %w", err)
}

authInfo := AuthInfo{State: state}
authInfo := AuthInfo{
State: state,
}

opts, err := state.Manifest.SNPValidateOpts()
if err != nil {
Expand Down
10 changes: 7 additions & 3 deletions coordinator/internal/authority/userapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/edgelesssys/contrast/coordinator/history"
"github.com/edgelesssys/contrast/internal/ca"
"github.com/edgelesssys/contrast/internal/constants"
"github.com/edgelesssys/contrast/internal/crypto"
"github.com/edgelesssys/contrast/internal/manifest"
"github.com/edgelesssys/contrast/internal/userapi"
Expand Down Expand Up @@ -50,11 +51,14 @@ func (a *Authority) SetManifest(ctx context.Context, req *userapi.SetManifestReq
}
} else if a.se.Load() == nil {
// First SetManifest call, initialize seed engine.
seedSalt, err := crypto.GenerateRandomBytes(64)
seed, err := crypto.GenerateRandomBytes(constants.SecretSeedSize)
if err != nil {
return nil, status.Errorf(codes.Internal, "generating random bytes: %v", err)
return nil, status.Errorf(codes.Internal, "generating random bytes for seed: %v", err)
}
salt, err := crypto.GenerateRandomBytes(constants.SecretSeedSaltSize)
if err != nil {
return nil, status.Errorf(codes.Internal, "generating random bytes for seed salt: %v", err)
}
seed, salt := seedSalt[:32], seedSalt[32:]

seedShares, err := manifest.EncryptSeedShares(seed, m.SeedshareOwnerPubKeys)
if err != nil {
Expand Down
16 changes: 5 additions & 11 deletions coordinator/internal/seedengine/seedengine.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
package seedengine

import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
Expand All @@ -20,8 +19,6 @@ import (
"golang.org/x/crypto/hkdf"
)

const hashSize = 32 // byte, SeedEngine.hashFun().Size()

// SeedEngine provides deterministic key derivation of ECDSA and symmetric keys
// from a secret seed.
type SeedEngine struct {
Expand Down Expand Up @@ -82,15 +79,12 @@ func New(secretSeed []byte, salt []byte) (*SeedEngine, error) {
return se, nil
}

// DerivePodSecret derives a secret for a pod from the policy hash and the secret seed.
func (s *SeedEngine) DerivePodSecret(policyHash [hashSize]byte) ([]byte, error) {
if policyHash == [hashSize]byte{} {
return nil, errors.New("policy hash must not be empty")
}
if bytes.Equal(policyHash[:], s.hashFun().Sum(nil)) {
return nil, errors.New("policy hash is the hash of an empty byte slice")
// DeriveWorkloadSecret derives a secret for a workload from the workload name and the secret seed.
func (s *SeedEngine) DeriveWorkloadSecret(workloadSecretID string) ([]byte, error) {
katexochen marked this conversation as resolved.
Show resolved Hide resolved
if workloadSecretID == "" {
return nil, errors.New("workload secret ID must not be empty")
}
return s.hkdfDerive(s.podStateSeed, fmt.Sprintf("POD SECRET %x", policyHash))
return s.hkdfDerive(s.podStateSeed, fmt.Sprintf("WORKLOAD SECRET ID: %s", workloadSecretID))
}

// GenerateMeshCAKey generates a new random key for the mesh authority.
Expand Down
33 changes: 12 additions & 21 deletions coordinator/internal/seedengine/seedengine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func TestSeedEngine_New(t *testing.T) {
}
}

func TestSeedEngine_DerivePodSecret(t *testing.T) {
func TestSeedEngine_DeriveWorkloadSecret(t *testing.T) {
require := require.New(t)

// policyHash -> want
Expand All @@ -93,43 +93,34 @@ func TestSeedEngine_DerivePodSecret(t *testing.T) {

DO NOT CHANGE!
*/
"8d62644ef9944dbbb1a2b1a574840cbd6b09e5e7f96ac0f82a8a37271edd983b": {podSecret: "27a9ce52ad64f131d7e44c655d4ab0b0ab41b38a538615d2b28f88cbfeac2c70"},
"b838a7adb60d110d6c3c7a1dfa51b439b78386f439a092eda0d67d53cc01c02e": {podSecret: "257172cbb64f1681f25168d46f361aa512c08c11c21ef6ad0b7d8b46ad29d443"},
"11103d1efce19d05f5aaac2c8af405136ad91dae9f64ba25c2402100ff0e03eb": {podSecret: "425b229b7f327ca82ee39941cce26ea84e6a78aef3358c0c98b76515129dac32"},
"d229c5714ca84d4e73b973636723e6cd5fe49f3c3e486732facfba61f94a10fc": {podSecret: "9e743b32c2fb0a9d791ba4cbd51445478d118ea88c4a0953576ed1ef4c1e353f"},
"91b7513a7709d2ab92d2c1fe1e431e37f0bea18165dd908b0e6386817b0c6faf": {podSecret: "86343cf90cecf6a1582465d50c33a6ef38dea6ca95e1424dc0bca37d5c8e076f"},
"99704c8b2a08ae9b8165a300ea05dbeae3b4c9a2096a6221aa4175bad43d53ec": {podSecret: "4006cbada495cb8f95e67f1b55466d63d94ca321789090bb80f01ae6c19ce8bf"},
"f2e57529d3b92832eef960b75b2d299aadf1e373473bebf28cc105dae55c5f4e": {podSecret: "66d4fd6a3bfeac05490a29e6e3c4191cb2400a1949d3b4bc726a08d12415eeb5"},
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855": {err: true},
"": {err: true},
"workload-1": {podSecret: "87668b2d30e7538b5643e42bcc0f1a7b532833f47fcd1293f779d9f2abf9e708"},
"emoji-pod ": {podSecret: "345f575cdd9fa8fbe61d35186266aaadd440a06db34beb52d85e3b678dc29e01"},
" ": {podSecret: "4874199bd19baf510bc5a5c71918c5263be4fb870efcf1bdd73e17249e3cb385"},
"12345": {podSecret: "c5dfeb23e39da9807d6260e6825d8367b47052fcb6bb4c79624fc5936921a0d0"},
"": {err: true},
}

secretSeed, err := hex.DecodeString("ccebed634ddee7535cd593e1e200b19b780f3906d8782207fa09c59e87a07cb3")
secretSeed, err := hex.DecodeString("9c7f285a46704602f8b6d9d4a89193579a979f144a9d8733fddd4f2bbcecd77f")
require.NoError(err)
salt, err := hex.DecodeString("8c1b1225c5f6cb7eef6dbd8f77a1e1e149de031d6e3718e660a8b04c8e2b0037")
salt, err := hex.DecodeString("6227b2cae740349beaff040af74aa1566ac330e9b54ce0e58f8d5ee47281745a")
require.NoError(err)

se, err := New(secretSeed, salt)
require.NoError(err)

for policyHashStr, want := range testCases {
t.Run(policyHashStr, func(t *testing.T) {
for workloadName, want := range testCases {
t.Run(workloadName, func(t *testing.T) {
assert := assert.New(t)

var policyHash [32]byte
policyHashSlice, err := hex.DecodeString(policyHashStr)
require.NoError(err)
copy(policyHash[:], policyHashSlice)

podSecret, err := se.DerivePodSecret(policyHash)
workloadSecret, err := se.DeriveWorkloadSecret(workloadName)

if want.err {
require.Error(err)
return
}
assert.NoError(err)

assert.Equal(want.podSecret, hex.EncodeToString(podSecret))
assert.Equal(want.podSecret, hex.EncodeToString(workloadSecret))
})
}
}
2 changes: 1 addition & 1 deletion coordinator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ func run() (retErr error) {

userapi.RegisterUserAPIServer(grpcServer, meshAuth)
serverMetrics.InitializeMetrics(grpcServer)
meshAPI := newMeshAPIServer(meshAuth, promRegistry, serverMetrics, logger)
meshAPI := newMeshAPIServer(meshAuth, promRegistry, serverMetrics, meshAuth, logger)
metricsServer := &http.Server{}

eg, ctx := errgroup.WithContext(ctx)
Expand Down
40 changes: 30 additions & 10 deletions coordinator/meshapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/edgelesssys/contrast/coordinator/internal/authority"
"github.com/edgelesssys/contrast/coordinator/internal/seedengine"
"github.com/edgelesssys/contrast/internal/atls"
"github.com/edgelesssys/contrast/internal/attestation/snp"
"github.com/edgelesssys/contrast/internal/manifest"
Expand All @@ -24,14 +25,17 @@ import (
)

type meshAPIServer struct {
grpc *grpc.Server
cleanup func()
logger *slog.Logger
grpc *grpc.Server
cleanup func()
seedEngineGetter seedEngineGetter
logger *slog.Logger

meshapi.UnimplementedMeshAPIServer
}

func newMeshAPIServer(meshAuth *authority.Authority, reg *prometheus.Registry, serverMetrics *grpcprometheus.ServerMetrics, log *slog.Logger) *meshAPIServer {
func newMeshAPIServer(meshAuth *authority.Authority, reg *prometheus.Registry, serverMetrics *grpcprometheus.ServerMetrics,
seedEngineGetter seedEngineGetter, log *slog.Logger,
) *meshAPIServer {
credentials, cancel := meshAuth.Credentials(reg, atls.NoIssuer)

grpcServer := grpc.NewServer(
Expand All @@ -45,9 +49,10 @@ func newMeshAPIServer(meshAuth *authority.Authority, reg *prometheus.Registry, s
),
)
s := &meshAPIServer{
grpc: grpcServer,
cleanup: cancel,
logger: log.WithGroup("meshapi"),
grpc: grpcServer,
cleanup: cancel,
seedEngineGetter: seedEngineGetter,
logger: log.WithGroup("meshapi"),
}
meshapi.RegisterMeshAPIServer(s.grpc, s)
serverMetrics.InitializeMetrics(s.grpc)
Expand Down Expand Up @@ -86,6 +91,11 @@ func (i *meshAPIServer) NewMeshCert(ctx context.Context, _ *meshapi.NewMeshCertR
report := authInfo.Report
tlsInfo := authInfo.TLSInfo

seedEngine, err := i.seedEngineGetter.GetSeedEngine()
if err != nil {
return nil, fmt.Errorf("failed to get seed engine: %w", err)
}

if len(tlsInfo.State.PeerCertificates) == 0 {
return nil, fmt.Errorf("no peer certificates found")
}
Expand Down Expand Up @@ -117,9 +127,19 @@ func (i *meshAPIServer) NewMeshCert(ctx context.Context, _ *meshapi.NewMeshCertR
return nil, fmt.Errorf("failed to issue new attested mesh cert: %w", err)
}

workloadSecret, err := seedEngine.DeriveWorkloadSecret(entry.WorkloadSecretID)
if err != nil {
return nil, fmt.Errorf("failed to derive workload secret: %w", err)
}

return &meshapi.NewMeshCertResponse{
MeshCACert: state.CA.GetMeshCACert(),
CertChain: append(cert, state.CA.GetIntermCACert()...),
RootCACert: state.CA.GetRootCACert(),
MeshCACert: state.CA.GetMeshCACert(),
CertChain: append(cert, state.CA.GetIntermCACert()...),
RootCACert: state.CA.GetRootCACert(),
WorkloadSecret: workloadSecret,
}, nil
}

type seedEngineGetter interface {
GetSeedEngine() (*seedengine.SeedEngine, error)
}
13 changes: 13 additions & 0 deletions docs/docs/architecture/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,16 @@ It needs to be provided with the secret seed, from which it can derive the signi
This procedure is called recovery and is initiated by the workload owner.
The CLI decrypts the secret seed using the private seed share owner key, verifies the Coordinator and sends the seed through the `Recover` method.
The Coordinator recovers its key material and verifies the manifest history signature.

## Workload Secrets

The Coordinator provides each workload a secret seed during attestation. This secret can be used by the workload to derive additional secrets for example to
encrypt persistent data. Like the workload certificates it's mounted in the shared Kubernetes volume `contrast-secrets` in the path `<mountpoint>/secrets/workload-secret-seed`.

:::warning

The workload owner can decrypt data encrypted with secrets derived from the workload secret.
The workload owner can derive the workload secret themselves, since it's derived from the secret seed known to the workload owner.
If the data owner and the workload owner is the same entity, then they can safely use the workload secrets.
3u13r marked this conversation as resolved.
Show resolved Hide resolved

:::
4 changes: 2 additions & 2 deletions docs/docs/components/service-mesh.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ Contrast service mesh as an init container.
- NET_ADMIN
privileged: true
volumeMounts:
- name: contrast-tls-certs
mountPath: /tls-config
- name: contrast-secrets
mountPath: /contrast
```

Note, that changing the environment variables of the sidecar container directly will
Expand Down
18 changes: 9 additions & 9 deletions docs/docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ spec: # v1.PodSpec

### Handling TLS

In the initialization process, the `contrast-tls-certs` shared volume is populated with X.509 certificates for your workload.
In the initialization process, the `contrast-secrets` shared volume is populated with X.509 certificates for your workload.
These certificates are used by the [Contrast Service Mesh](components/service-mesh.md), but can also be used by your application directly.
The following tab group explains the setup for both scenarios.

Expand Down Expand Up @@ -120,9 +120,9 @@ The following example shows how to configure a Golang app, with error handling o

```go
caCerts := x509.NewCertPool()
caCert, _ := os.ReadFile("/tls-config/mesh-ca.pem")
caCert, _ := os.ReadFile("/contrast/tls-config/mesh-ca.pem")
caCerts.AppendCertsFromPEM(caCert)
cert, _ := tls.LoadX509KeyPair("/tls-config/certChain.pem", "/tls-config/key.pem")
cert, _ := tls.LoadX509KeyPair("/contrast/tls-config/certChain.pem", "/contrast/tls-config/key.pem")
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caCerts,
Expand All @@ -134,9 +134,9 @@ cfg := &tls.Config{

```go
caCerts := x509.NewCertPool()
caCert, _ := os.ReadFile("/tls-config/mesh-ca.pem")
caCert, _ := os.ReadFile("/contrast/tls-config/mesh-ca.pem")
caCerts.AppendCertsFromPEM(caCert)
cert, _ := tls.LoadX509KeyPair("/tls-config/certChain.pem", "/tls-config/key.pem")
cert, _ := tls.LoadX509KeyPair("/contrast/tls-config/certChain.pem", "/contrast/tls-config/key.pem")
cfg := &tls.Config{
Certificates: []tls.Certificate{cert},
ClientAuth: tls.RequireAndVerifyClientCert,
Expand Down Expand Up @@ -199,7 +199,7 @@ metadata: # apps/v1.Deployment, apps/v1.DaemonSet, ...
When disabling the automatic Initializer injection, you can manually add the
Initializer as a sidecar container to your workload before generating the
policies. Configure the workload to use the certificates written to the
`contrast-tls-certs` `volumeMount`.
`contrast-secrets` `volumeMount`.

```yaml
# v1.PodSpec
Expand All @@ -211,11 +211,11 @@ spec:
image: "ghcr.io/edgelesssys/contrast/initializer:latest"
name: contrast-initializer
volumeMounts:
- mountPath: /tls-config
name: contrast-tls-certs
- mountPath: /contrast
name: contrast-secrets
volumes:
- emptyDir: {}
name: contrast-tls-certs
name: contrast-secrets
```

## Apply the resources
Expand Down
15 changes: 9 additions & 6 deletions docs/docs/examples/emojivoto.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,15 @@ the list that already contains the `"web"` DNS entry:
```diff
"Policies": {
...
"99dd77cbd7fe2c4e1f29511014c14054a21a376f7d58a48d50e9e036f4522f6b": [
"web",
- "*"
+ "*",
+ "203.0.113.34"
],
"99dd77cbd7fe2c4e1f29511014c14054a21a376f7d58a48d50e9e036f4522f6b": {
"SANs": [
"web",
- "*"
+ "*",
+ "203.0.113.34"
],
"WorkloadSecretID": "web"
},
```

### Update the manifest
Expand Down
10 changes: 10 additions & 0 deletions e2e/internal/kubeclient/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

appsv1 "k8s.io/api/apps/v1"
autoscalingv1 "k8s.io/api/autoscaling/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
Expand Down Expand Up @@ -363,6 +364,15 @@ func (c *Kubeclient) Restart(ctx context.Context, resource ResourceWaiter, names
return nil
}

// ScaleDeployment scales a deployment to the given number of replicas.
func (c *Kubeclient) ScaleDeployment(ctx context.Context, namespace, name string, replicas int32) error {
_, err := c.client.AppsV1().Deployments(namespace).UpdateScale(ctx, name, &autoscalingv1.Scale{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: autoscalingv1.ScaleSpec{Replicas: replicas},
}, metav1.UpdateOptions{})
return err
}

func toPtr[T any](t T) *T {
return &t
}
Loading