diff --git a/config-provisioning/src/main/java/com/yahoo/config/provision/Cloud.java b/config-provisioning/src/main/java/com/yahoo/config/provision/Cloud.java index 463d9edcdad9..d388d255933c 100644 --- a/config-provisioning/src/main/java/com/yahoo/config/provision/Cloud.java +++ b/config-provisioning/src/main/java/com/yahoo/config/provision/Cloud.java @@ -2,6 +2,7 @@ package com.yahoo.config.provision; import java.util.Objects; +import java.util.Optional; /** * Properties of the cloud service where the zone is deployed. @@ -17,15 +18,17 @@ public class Cloud { private final boolean allowEnclave; private final boolean requireAccessControl; private final CloudAccount account; + private final Optional snapshotPrivateKeySecretName; private Cloud(CloudName name, boolean dynamicProvisioning, boolean allowHostSharing, boolean allowEnclave, - boolean requireAccessControl, CloudAccount account) { + boolean requireAccessControl, CloudAccount account, Optional snapshotPrivateKeySecretName) { this.name = Objects.requireNonNull(name); this.dynamicProvisioning = dynamicProvisioning; this.allowHostSharing = allowHostSharing; this.allowEnclave = allowEnclave; this.requireAccessControl = requireAccessControl; this.account = Objects.requireNonNull(account); + this.snapshotPrivateKeySecretName = Objects.requireNonNull(snapshotPrivateKeySecretName); if (allowEnclave && account.isUnspecified()) { throw new IllegalArgumentException("Account must be non-empty in '" + name + "'"); } @@ -62,6 +65,11 @@ public boolean useProxyProtocol() { return !name.equals(CloudName.AZURE); } + /** Name of private key secret used for sealing snapshot encryption keys */ + public Optional snapshotPrivateKeySecretName() { + return snapshotPrivateKeySecretName; + } + /** For testing purposes only */ public static Cloud defaultCloud() { return new Builder().build(); @@ -79,6 +87,7 @@ public static class Builder { private boolean allowEnclave = false; private boolean requireAccessControl = false; private CloudAccount account = CloudAccount.empty; + private Optional snapshotPrivateKeySecretName = Optional.empty(); public Builder() {} @@ -112,8 +121,14 @@ public Builder account(CloudAccount account) { return this; } + public Builder snapshotPrivateKeySecretName(String snapshotPrivateKeySecretName) { + this.snapshotPrivateKeySecretName = Optional.of(snapshotPrivateKeySecretName) + .filter(s -> !s.isEmpty()); + return this; + } + public Cloud build() { - return new Cloud(name, dynamicProvisioning, allowHostSharing, allowEnclave, requireAccessControl, account); + return new Cloud(name, dynamicProvisioning, allowHostSharing, allowEnclave, requireAccessControl, account, snapshotPrivateKeySecretName); } } diff --git a/config-provisioning/src/main/java/com/yahoo/config/provision/Zone.java b/config-provisioning/src/main/java/com/yahoo/config/provision/Zone.java index e232cd6be834..8c1b92d8a79d 100644 --- a/config-provisioning/src/main/java/com/yahoo/config/provision/Zone.java +++ b/config-provisioning/src/main/java/com/yahoo/config/provision/Zone.java @@ -30,6 +30,7 @@ public Zone(ConfigserverConfig configserverConfig, CloudConfig cloudConfig) { .allowEnclave(configserverConfig.cloud().equals("aws") || configserverConfig.cloud().equals("gcp")) .requireAccessControl(cloudConfig.requireAccessControl()) .account(CloudAccount.from(cloudConfig.account())) + .snapshotPrivateKeySecretName(cloudConfig.snapshotPrivateKeySecretName()) .build(), SystemName.from(configserverConfig.system()), Environment.from(configserverConfig.environment()), diff --git a/config-provisioning/src/main/resources/configdefinitions/config.provisioning.cloud.def b/config-provisioning/src/main/resources/configdefinitions/config.provisioning.cloud.def index 6e40b3192d5e..ed41aa5931c4 100644 --- a/config-provisioning/src/main/resources/configdefinitions/config.provisioning.cloud.def +++ b/config-provisioning/src/main/resources/configdefinitions/config.provisioning.cloud.def @@ -17,3 +17,6 @@ account string default="" # The cloud-specific region for this zone (as opposed to the Vespa region). region string default="" + +# Name of private key secret used for sealing snapshot encryption keys. +snapshotPrivateKeySecretName string default="" diff --git a/node-repository/pom.xml b/node-repository/pom.xml index c63ce62f7c11..5b896a89941a 100644 --- a/node-repository/pom.xml +++ b/node-repository/pom.xml @@ -76,6 +76,12 @@ ${project.version} provided + + com.yahoo.vespa + security-utils + ${project.version} + provided + diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/NodeRepository.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/NodeRepository.java index 975c66129e25..d752ec8cdfb6 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/NodeRepository.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/NodeRepository.java @@ -1,6 +1,7 @@ // Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision; +import ai.vespa.secret.internal.TypedSecretStore; import com.yahoo.component.AbstractComponent; import com.yahoo.component.annotation.Inject; import com.yahoo.concurrent.maintenance.JobControl; @@ -90,7 +91,8 @@ public NodeRepository(NodeRepositoryConfig config, Zone zone, FlagSource flagSource, MetricsDb metricsDb, - Orchestrator orchestrator) { + Orchestrator orchestrator, + TypedSecretStore secretStore) { this(flavors, provisionServiceProvider, curator, @@ -104,7 +106,8 @@ public NodeRepository(NodeRepositoryConfig config, metricsDb, orchestrator, config.useCuratorClientCache(), - zone.environment().isProduction() && !zone.cloud().dynamicProvisioning() && !zone.system().isCd() ? 1 : 0); + zone.environment().isProduction() && !zone.cloud().dynamicProvisioning() && !zone.system().isCd() ? 1 : 0, + secretStore); } /** @@ -124,7 +127,8 @@ public NodeRepository(NodeFlavors flavors, MetricsDb metricsDb, Orchestrator orchestrator, boolean useCuratorClientCache, - int spareCount) { + int spareCount, + TypedSecretStore secretStore) { if (provisionServiceProvider.getHostProvisioner().isPresent() != zone.cloud().dynamicProvisioning()) throw new IllegalArgumentException(String.format( "dynamicProvisioning property must be 1-to-1 with availability of HostProvisioner, was: dynamicProvisioning=%s, hostProvisioner=%s", @@ -135,7 +139,7 @@ public NodeRepository(NodeFlavors flavors, this.clock = clock; this.zone = zone; this.applications = new Applications(db); - this.snapshots = new Snapshots(this, provisionServiceProvider.getSnapshotStore()); + this.snapshots = new Snapshots(this, secretStore, provisionServiceProvider.getSnapshotStore(), zone.cloud().snapshotPrivateKeySecretName()); this.nodes = new Nodes(db, zone, clock, orchestrator, applications, snapshots, flagSource); this.flavors = flavors; this.resourcesCalculator = provisionServiceProvider.getHostResourcesCalculator(); diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/Snapshot.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/Snapshot.java index 70509ccc1329..0e1720e6bf33 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/Snapshot.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/Snapshot.java @@ -21,7 +21,7 @@ * @author mpolden */ public record Snapshot(SnapshotId id, HostName hostname, State state, History history, ClusterId cluster, - int clusterIndex, CloudAccount cloudAccount) { + int clusterIndex, CloudAccount cloudAccount, Optional key) { public Snapshot { Objects.requireNonNull(id); @@ -29,6 +29,7 @@ public record Snapshot(SnapshotId id, HostName hostname, State state, History hi Objects.requireNonNull(hostname); Objects.requireNonNull(history); Objects.requireNonNull(cluster); + Objects.requireNonNull(key); if (clusterIndex < 0) { throw new IllegalArgumentException("clusterIndex cannot be negative, got " + cluster); } @@ -47,7 +48,7 @@ public Snapshot with(State state, Instant at) { if (!canChangeTo(state)) { throw new IllegalArgumentException("Cannot change state of " + this + " to " + state); } - return new Snapshot(id, hostname, state, history.with(state, at), cluster, clusterIndex, cloudAccount); + return new Snapshot(id, hostname, state, history.with(state, at), cluster, clusterIndex, cloudAccount, key); } private boolean canChangeTo(State state) { @@ -122,8 +123,10 @@ public static SnapshotId generateId() { return new SnapshotId(UUID.randomUUID()); } - public static Snapshot create(SnapshotId id, HostName hostname, CloudAccount cloudAccount, Instant at, ClusterId cluster, int clusterIndex) { - return new Snapshot(id, hostname, State.creating, History.of(State.creating, at), cluster, clusterIndex, cloudAccount); + public static Snapshot create(SnapshotId id, HostName hostname, CloudAccount cloudAccount, Instant at, + ClusterId cluster, int clusterIndex, SnapshotKey encryptionKey) { + return new Snapshot(id, hostname, State.creating, History.of(State.creating, at), cluster, clusterIndex, + cloudAccount, Optional.of(encryptionKey)); } } diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/SnapshotKey.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/SnapshotKey.java new file mode 100644 index 000000000000..4060398c2bd4 --- /dev/null +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/SnapshotKey.java @@ -0,0 +1,20 @@ +package com.yahoo.vespa.hosted.provision.backup; + +import ai.vespa.secret.model.SecretVersionId; +import com.yahoo.security.SealedSharedKey; + +import java.util.Objects; + +/** + * The sealed encryption key for a {@link Snapshot}. + * + * @author mpolden + */ +public record SnapshotKey(SealedSharedKey sharedKey, SecretVersionId sealingKeyVersion) { + + public SnapshotKey { + Objects.requireNonNull(sharedKey); + Objects.requireNonNull(sealingKeyVersion); + } + +} diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/Snapshots.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/Snapshots.java index 9bc250f928b2..9ecc386bb98d 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/Snapshots.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/backup/Snapshots.java @@ -1,11 +1,20 @@ // Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.backup; +import ai.vespa.secret.internal.TypedSecretStore; +import ai.vespa.secret.model.Key; +import ai.vespa.secret.model.Secret; +import ai.vespa.secret.model.SecretVersionId; import com.yahoo.config.provision.ClusterMembership; import com.yahoo.config.provision.ClusterSpec; import com.yahoo.config.provision.HostName; import com.yahoo.config.provision.NodeType; import com.yahoo.config.provision.SnapshotId; +import com.yahoo.security.KeyId; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SealedSharedKey; +import com.yahoo.security.SecretSharedKey; +import com.yahoo.security.SharedKeyGenerator; import com.yahoo.transaction.Mutex; import com.yahoo.transaction.NestedTransaction; import com.yahoo.vespa.curator.Lock; @@ -18,6 +27,10 @@ import com.yahoo.vespa.hosted.provision.persistence.CuratorDb; import com.yahoo.vespa.hosted.provision.provisioning.SnapshotStore; +import java.security.KeyPair; +import java.security.PublicKey; +import java.security.interfaces.XECPrivateKey; +import java.security.interfaces.XECPublicKey; import java.time.Instant; import java.util.ArrayList; import java.util.List; @@ -35,11 +48,17 @@ public class Snapshots { private final NodeRepository nodeRepository; private final CuratorDb db; private final Optional snapshotStore; + private final TypedSecretStore secretStore; + private final Optional sealingPrivateKeySecretName; - public Snapshots(NodeRepository nodeRepository, Optional snapshotStore) { + public Snapshots(NodeRepository nodeRepository, TypedSecretStore secretStore, + Optional snapshotStore, + Optional sealingPrivateKeySecretName) { this.nodeRepository = Objects.requireNonNull(nodeRepository); this.db = nodeRepository.database(); this.snapshotStore = Objects.requireNonNull(snapshotStore); + this.secretStore = Objects.requireNonNull(secretStore); + this.sealingPrivateKeySecretName = Objects.requireNonNull(sealingPrivateKeySecretName); } /** Read known backup snapshots, for all hosts */ @@ -67,8 +86,10 @@ public Snapshot require(SnapshotId id, String hostname) { /** Trigger a new snapshot for node of given hostname */ public Snapshot create(String hostname, Instant instant) { + VersionedKeyPair keyPair = sealingKeyPair(); try (var lock = lock(hostname)) { SnapshotId id = Snapshot.generateId(); + SecretSharedKey encryptionKey = generateEncryptionKey(keyPair.keyPair(), id); return write(id, hostname, (node) -> { if (busy(node)) { throw new IllegalArgumentException("Cannot trigger new snapshot: Node " + hostname + @@ -76,7 +97,8 @@ public Snapshot create(String hostname, Instant instant) { } ClusterId cluster = new ClusterId(node.allocation().get().owner(), node.allocation().get().membership().cluster().id()); return Snapshot.create(id, com.yahoo.config.provision.HostName.of(hostname), node.cloudAccount(), - instant, cluster, node.allocation().get().membership().index()); + instant, cluster, node.allocation().get().membership().index(), + new SnapshotKey(encryptionKey.sealedSharedKey(), keyPair.version())); }, lock); } } @@ -84,19 +106,25 @@ public Snapshot create(String hostname, Instant instant) { /** Restore a node to given snapshot */ public Snapshot restore(SnapshotId id, String hostname) { try (var lock = lock(hostname)) { - Snapshot snapshot = require(id, hostname); + Snapshot original = require(id, hostname); Instant now = nodeRepository.clock().instant(); return write(id, hostname, (node) -> { if (busy(node)) { throw new IllegalArgumentException("Cannot restore snapshot: Node " + hostname + - " is busy with snapshot " + node.status().snapshot().get().id() + " in "+ + " is busy with snapshot " + node.status().snapshot().get().id() + " in " + node.status().snapshot().get().state() + " state"); } - return snapshot.with(Snapshot.State.restoring, now); + return original.with(Snapshot.State.restoring, now); }, lock); } } + /** Returns the encryption key for snapshot, sealed with given public key */ + public SealedSharedKey keyOf(SnapshotId id, String hostname, PublicKey sealingKey) { + Snapshot snapshot = require(id, hostname); + return resealKeyOf(snapshot, sealingKey); + } + /** Remove given snapshot */ public void remove(SnapshotId id, String hostname, boolean force) { try (var lock = lock(hostname)) { @@ -147,6 +175,39 @@ public Snapshot move(SnapshotId id, String hostname, Snapshot.State newState) { } } + private SecretSharedKey generateEncryptionKey(KeyPair keyPair, SnapshotId id) { + return SharedKeyGenerator.generateForReceiverPublicKey(keyPair.getPublic(), + KeyId.ofString(id.toString())); + } + + /** Reseal the encryption key for snapshot using given public key */ + private SealedSharedKey resealKeyOf(Snapshot snapshot, PublicKey receiverPublicKey) { + if (snapshot.key().isEmpty()) { + throw new IllegalArgumentException("Snapshot " + snapshot.id() + " has no encryption key"); + } + VersionedKeyPair sealingKeyPair = sealingKeyPair(snapshot.key().get().sealingKeyVersion()); + SecretSharedKey unsealedKey = SharedKeyGenerator.fromSealedKey(snapshot.key().get().sharedKey(), + sealingKeyPair.keyPair().getPrivate()); + return SharedKeyGenerator.reseal(unsealedKey, receiverPublicKey, KeyId.ofString(snapshot.id().toString())) + .sealedSharedKey(); + } + + /** Returns the key pair to use when sealing the snapshot encryption key */ + private VersionedKeyPair sealingKeyPair(SecretVersionId version) { + if (sealingPrivateKeySecretName.isEmpty()) { + throw new IllegalArgumentException("Cannot retrieve sealing key because its secret name is unset"); + } + Key key = Key.fromString(sealingPrivateKeySecretName.get()); + Secret sealingPrivateKey = version == null ? secretStore.getSecret(key) : secretStore.getSecret(key, version); + XECPrivateKey privateKey = KeyUtils.fromBase64EncodedX25519PrivateKey(sealingPrivateKey.secretValue().value()); + XECPublicKey publicKey = KeyUtils.extractX25519PublicKey(privateKey); + return new VersionedKeyPair(new KeyPair(publicKey, privateKey), sealingPrivateKey.version()); + } + + private VersionedKeyPair sealingKeyPair() { + return sealingKeyPair(null); + } + private boolean active(SnapshotId id, Node node) { return node.status().snapshot().isPresent() && node.status().snapshot().get().id().equals(id); } @@ -192,4 +253,6 @@ private static Node requireNode(NodeMutex nodeMutex) { return node; } + private record VersionedKeyPair(KeyPair keyPair, SecretVersionId version) {} + } diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/SnapshotSerializer.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/SnapshotSerializer.java index dffb3673630e..56e39374d255 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/SnapshotSerializer.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/persistence/SnapshotSerializer.java @@ -1,22 +1,26 @@ // Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.persistence; +import ai.vespa.secret.model.SecretVersionId; import com.google.common.collect.ImmutableMap; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.CloudAccount; import com.yahoo.config.provision.ClusterSpec; import com.yahoo.config.provision.HostName; +import com.yahoo.config.provision.SnapshotId; +import com.yahoo.security.SealedSharedKey; import com.yahoo.slime.ArrayTraverser; import com.yahoo.slime.Cursor; import com.yahoo.slime.Inspector; import com.yahoo.slime.Slime; import com.yahoo.slime.SlimeUtils; import com.yahoo.vespa.hosted.provision.backup.Snapshot; -import com.yahoo.config.provision.SnapshotId; +import com.yahoo.vespa.hosted.provision.backup.SnapshotKey; import com.yahoo.vespa.hosted.provision.node.ClusterId; import java.time.Instant; import java.util.List; +import java.util.Optional; /** * @author mpolden @@ -34,6 +38,8 @@ public class SnapshotSerializer { private static final String EVENT_FIELD = "event"; private static final String AT_FIELD = "at"; private static final String CLOUD_ACCOUNT_FIELD = "cloudAccount"; + private static final String SEALED_SHARED_KEY_FIELD = "sealedSharedKey"; + private static final String SEALING_KEY_VERSION = "sealingKeyVersion"; private SnapshotSerializer() {} @@ -48,6 +54,12 @@ public static Snapshot fromInspector(Inspector object, CloudAccount systemAccoun CloudAccount cloudAccount = SlimeUtils.optionalString(object.field(CLOUD_ACCOUNT_FIELD)) .map(CloudAccount::from) .orElse(systemAccount); + Optional encryptionKey = Optional.empty(); + if (object.field(SEALED_SHARED_KEY_FIELD).valid()) { + SealedSharedKey sharedKey = SealedSharedKey.fromTokenString(object.field(SEALED_SHARED_KEY_FIELD).asString()); + SecretVersionId sealingKeyVersion = SecretVersionId.of(object.field(SEALING_KEY_VERSION).asString()); + encryptionKey = Optional.of(new SnapshotKey(sharedKey, sealingKeyVersion)); + } return new Snapshot(SnapshotId.of(object.field(ID_FIELD).asString()), HostName.of(object.field(HOSTNAME_FIELD).asString()), stateFromSlime(object.field(STATE_FIELD).asString()), @@ -55,7 +67,8 @@ public static Snapshot fromInspector(Inspector object, CloudAccount systemAccoun new ClusterId(ApplicationId.fromSerializedForm(object.field(INSTANCE_FIELD).asString()), ClusterSpec.Id.from(object.field(CLUSTER_FIELD).asString())), (int) object.field(CLUSTER_INDEX_FIELD).asLong(), - cloudAccount + cloudAccount, + encryptionKey ); } @@ -100,6 +113,10 @@ public static void toSlime(Snapshot snapshot, Cursor object) { eventObject.setLong(AT_FIELD, event.at().toEpochMilli()); }); object.setString(CLOUD_ACCOUNT_FIELD, snapshot.cloudAccount().value()); + snapshot.key().ifPresent(k -> { + object.setString(SEALED_SHARED_KEY_FIELD, k.sharedKey().toTokenString()); + object.setString(SEALING_KEY_VERSION, k.sealingKeyVersion().value()); + }); } public static String asString(Snapshot.State state) { diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesResponse.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesResponse.java index 10095e20083a..7a6507f9847f 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesResponse.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesResponse.java @@ -248,6 +248,7 @@ static void toSlime(WireguardKeyWithTimestamp keyWithTimestamp, Cursor object) { private void toSlime(Snapshot snapshot, Cursor object) { object.setString("id", snapshot.id().toString()); object.setString("state", SnapshotSerializer.asString(snapshot.state())); + object.setBool("encrypted", snapshot.key().isPresent()); } private Optional currentContainerImage(Node node) { diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiHandler.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiHandler.java index 471f0840bddb..0a5dd5753acc 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiHandler.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiHandler.java @@ -13,6 +13,7 @@ import com.yahoo.config.provision.NodeFlavors; import com.yahoo.config.provision.NodeResources; import com.yahoo.config.provision.NodeType; +import com.yahoo.config.provision.SnapshotId; import com.yahoo.config.provision.TenantName; import com.yahoo.config.provision.security.NodePrincipal; import com.yahoo.container.jdisc.HttpRequest; @@ -24,6 +25,8 @@ import com.yahoo.restapi.Path; import com.yahoo.restapi.ResourceResponse; import com.yahoo.restapi.SlimeJsonResponse; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SealedSharedKey; import com.yahoo.slime.ArrayTraverser; import com.yahoo.slime.Cursor; import com.yahoo.slime.Inspector; @@ -37,7 +40,6 @@ import com.yahoo.vespa.hosted.provision.applications.Application; import com.yahoo.vespa.hosted.provision.autoscale.Load; import com.yahoo.vespa.hosted.provision.backup.Snapshot; -import com.yahoo.config.provision.SnapshotId; import com.yahoo.vespa.hosted.provision.maintenance.InfraApplicationRedeployer; import com.yahoo.vespa.hosted.provision.node.Agent; import com.yahoo.vespa.hosted.provision.node.IP; @@ -57,6 +59,7 @@ import java.io.UncheckedIOException; import java.net.URI; import java.net.URISyntaxException; +import java.security.PublicKey; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -243,10 +246,24 @@ private HttpResponse handlePOST(HttpRequest request) { } if (path.matches("/nodes/v2/snapshot/{hostname}")) return snapshot(path.get("hostname")); if (path.matches("/nodes/v2/snapshot/{hostname}/{snapshot}/restore")) return restoreSnapshot(SnapshotId.of(path.get("snapshot")), path.get("hostname")); + if (path.matches("/nodes/v2/snapshot/{hostname}/{snapshot}/key")) return snapshotEncryptionKey(SnapshotId.of(path.get("snapshot")), path.get("hostname"), toSlime(request)); throw new NotFoundException("Nothing at path '" + request.getUri().getPath() + "'"); } + private HttpResponse snapshotEncryptionKey(SnapshotId id, String hostname, Inspector body) { + Inspector sealingKeyField = body.field("sealingKey"); + if (!sealingKeyField.valid()) { + throw new IllegalArgumentException("No 'sealingKey' field present in body"); + } + PublicKey sealingKey = KeyUtils.fromBase64EncodedX25519PublicKey(sealingKeyField.asString()); + SealedSharedKey key = nodeRepository.snapshots().keyOf(id, hostname, sealingKey); + Slime slime = new Slime(); + Cursor root = slime.setObject(); + root.setString("sealedSharedKey", key.toTokenString()); + return new SlimeJsonResponse(slime); + } + private HttpResponse snapshot(String hostname) { Snapshot snapshot = nodeRepository.snapshots().create(hostname, nodeRepository.clock().instant()); return new MessageResponse("Triggered a new snapshot of " + hostname + ": " + snapshot.id()); diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/ContainerConfig.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/ContainerConfig.java index 5795e25d247a..a0b90e72448a 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/ContainerConfig.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/ContainerConfig.java @@ -23,6 +23,7 @@ public static String servicesXmlV2(int port, SystemName systemName, CloudAccount %s + snapshot/sealingPrivateKey @@ -38,6 +39,7 @@ public static String servicesXmlV2(int port, SystemName systemName, CloudAccount + diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java index b55b961fea36..d8d6b5ad0a2d 100644 --- a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/MockNodeRepository.java @@ -1,6 +1,7 @@ // Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.testutils; +import ai.vespa.secret.internal.TypedSecretStore; import com.yahoo.component.Version; import com.yahoo.config.provision.ActivationContext; import com.yahoo.config.provision.ApplicationId; @@ -12,7 +13,6 @@ import com.yahoo.config.provision.ClusterResources; import com.yahoo.config.provision.ClusterSpec; import com.yahoo.config.provision.DockerImage; -import com.yahoo.config.provision.Exclusivity; import com.yahoo.config.provision.Flavor; import com.yahoo.config.provision.HostSpec; import com.yahoo.config.provision.InstanceName; @@ -20,7 +20,6 @@ import com.yahoo.config.provision.NodeFlavors; import com.yahoo.config.provision.NodeResources; import com.yahoo.config.provision.NodeType; -import com.yahoo.config.provision.SharedHosts; import com.yahoo.config.provision.TenantName; import com.yahoo.config.provision.WireguardKey; import com.yahoo.config.provision.WireguardKeyWithTimestamp; @@ -86,7 +85,7 @@ public class MockNodeRepository extends NodeRepository { * @param flavors flavors to have in node repo */ @Inject - public MockNodeRepository(MockCurator curator, NodeFlavors flavors, Zone zone) { + public MockNodeRepository(MockCurator curator, NodeFlavors flavors, Zone zone, TypedSecretStore secretStore) { super(flavors, new EmptyProvisionServiceProvider(), curator, @@ -100,7 +99,8 @@ public MockNodeRepository(MockCurator curator, NodeFlavors flavors, Zone zone) { new MemoryMetricsDb(Clock.fixed(Instant.ofEpochMilli(123), ZoneId.of("Z"))), new OrchestratorMock(), true, - 0); + 0, + secretStore); this.flavors = flavors; defaultCloudAccount = zone.cloud().account(); diff --git a/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/SecretStoreMock.java b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/SecretStoreMock.java new file mode 100644 index 000000000000..87a7130dc306 --- /dev/null +++ b/node-repository/src/main/java/com/yahoo/vespa/hosted/provision/testutils/SecretStoreMock.java @@ -0,0 +1,61 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.provision.testutils; + +import ai.vespa.secret.internal.TypedSecretStore; +import ai.vespa.secret.model.Key; +import ai.vespa.secret.model.Secret; +import ai.vespa.secret.model.SecretVersionId; +import com.yahoo.component.AbstractComponent; + +import java.util.Comparator; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +/** + * @author mpolden + */ +public class SecretStoreMock extends AbstractComponent implements TypedSecretStore { + + private final Set secrets = new HashSet<>(); + + @Override + public Secret getSecret(Key key) { + return findSecret(key, Optional.empty()); + } + + @Override + public Secret getSecret(Key key, SecretVersionId version) { + return findSecret(key, Optional.of(version)); + } + + @Override + public Type type() { + return Type.TEST; + } + + @Override + public String getSecret(String key) { + return getSecret(Key.fromString(key)).secretAsString(); + } + + @Override + public String getSecret(String key, int version) { + return getSecret(Key.fromString(key), SecretVersionId.of(Integer.toString(version))).secretAsString(); + } + + public SecretStoreMock add(Secret secret) { + secrets.add(secret); + return this; + } + + private Secret findSecret(Key key, Optional version) { + return secrets.stream().filter(s -> s.secretName().equals(key.secretName()) && + s.vaultName().equals(key.vaultName()) && + (version.isEmpty() || version.get().equals(s.version()))) + .min(Comparator.naturalOrder()) // Choose the highest version. For some reason Secret::compareTo sorts version in reverse order?! + .orElseThrow(() -> new IllegalArgumentException("No secret found for key=" + key + + ", version=" + version.map(Record::toString).orElse("any"))); + } + +} diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/NodeRepositoryTester.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/NodeRepositoryTester.java index 04d2cd9b575f..128c8c7f8d16 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/NodeRepositoryTester.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/NodeRepositoryTester.java @@ -2,11 +2,9 @@ package com.yahoo.vespa.hosted.provision; import com.yahoo.config.provision.DockerImage; -import com.yahoo.config.provision.Exclusivity; import com.yahoo.config.provision.Flavor; import com.yahoo.config.provision.NodeFlavors; import com.yahoo.config.provision.NodeType; -import com.yahoo.config.provision.SharedHosts; import com.yahoo.config.provision.Zone; import com.yahoo.config.provisioning.FlavorsConfig; import com.yahoo.test.ManualClock; @@ -19,6 +17,7 @@ import com.yahoo.vespa.hosted.provision.provisioning.FlavorConfigBuilder; import com.yahoo.vespa.hosted.provision.testutils.MockNameResolver; import com.yahoo.vespa.hosted.provision.testutils.OrchestratorMock; +import com.yahoo.vespa.hosted.provision.testutils.SecretStoreMock; import java.util.List; import java.util.Optional; @@ -55,7 +54,8 @@ public NodeRepositoryTester(Zone zone) { new MemoryMetricsDb(clock), new OrchestratorMock(), true, - 0); + 0, + new SecretStoreMock()); } public NodeRepository nodeRepository() { return nodeRepository; } diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/backup/SnapshotTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/backup/SnapshotTest.java index 7cf41f6cdd8b..a3a1d48fdaad 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/backup/SnapshotTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/backup/SnapshotTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test; import java.time.Instant; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.fail; @@ -48,7 +49,7 @@ private static void assertDisallowed(Snapshot.State from, Snapshot.State to) { private static Snapshot snapshot(Snapshot.State state) { return new Snapshot(Snapshot.generateId(), HostName.of("h1.example.com"), state, Snapshot.History.of(state, Instant.ofEpochMilli(123)), new ClusterId(ApplicationId.defaultId(), ClusterSpec.Id.from("c1")), - 0, CloudAccount.empty); + 0, CloudAccount.empty, Optional.empty()); } } diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/backup/SnapshotsTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/backup/SnapshotsTest.java new file mode 100644 index 000000000000..cc3928550829 --- /dev/null +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/backup/SnapshotsTest.java @@ -0,0 +1,72 @@ +package com.yahoo.vespa.hosted.provision.backup; + +import ai.vespa.secret.model.Key; +import ai.vespa.secret.model.Secret; +import ai.vespa.secret.model.SecretVersionId; +import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.Capacity; +import com.yahoo.config.provision.ClusterResources; +import com.yahoo.config.provision.ClusterSpec; +import com.yahoo.config.provision.NodeResources; +import com.yahoo.config.provision.NodeType; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SealedSharedKey; +import com.yahoo.vespa.hosted.provision.Node; +import com.yahoo.vespa.hosted.provision.provisioning.ProvisioningTester; +import org.junit.jupiter.api.Test; + +import java.security.KeyPair; +import java.security.PublicKey; +import java.security.interfaces.XECPrivateKey; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * @author mpolden + */ +class SnapshotsTest { + + @Test + void snapshot() { + ProvisioningTester tester = new ProvisioningTester.Builder().build(); + tester.makeReadyHosts(3, new NodeResources(8, 32, 1000, 10)) + .prepareAndActivateInfraApplication(NodeType.host); + + // Deploy app + ApplicationId app = ProvisioningTester.applicationId(); + ClusterSpec clusterSpec = ClusterSpec.request(ClusterSpec.Type.content, ClusterSpec.Id.from("c1")).vespaVersion("8.0").build(); + int nodeCount = 3; + NodeResources nodeResources = new NodeResources(4, 16, 100, 10); + List nodes = tester.deploy(app, clusterSpec, Capacity.from(new ClusterResources(nodeCount, 1, nodeResources))); + + // Create encrypted snapshot + Snapshots snapshots = tester.nodeRepository().snapshots(); + String node0 = nodes.get(0).hostname(); + Snapshot snapshot0 = snapshots.create(node0, tester.clock().instant()); + assertTrue(snapshot0.key().isPresent()); + + // Request snapshot key + PublicKey receiverPublicKey = KeyUtils.generateX25519KeyPair().getPublic(); + SealedSharedKey resealedKey = snapshots.keyOf(snapshot0.id(), node0, receiverPublicKey); + assertNotEquals(snapshot0.key().get().sharedKey(), resealedKey); + + // Sealing key can be rotated independently of existing snapshots + KeyPair keyPair = KeyUtils.generateX25519KeyPair(); + tester.secretStore().add(new Secret(Key.fromString("snapshot/sealingPrivateKey"), + KeyUtils.toBase64EncodedX25519PrivateKey((XECPrivateKey) keyPair.getPrivate()) + .getBytes(), + SecretVersionId.of("2"))); + assertEquals(SecretVersionId.of("1"), snapshots.require(snapshot0.id(), node0).key().get().sealingKeyVersion()); + assertNotEquals(snapshot0.key().get().sharedKey(), snapshots.keyOf(snapshot0.id(), node0, receiverPublicKey), + "Can reseal after key rotation"); + + // Next snapshot uses latest sealing key + String node1 = nodes.get(1).hostname(); + Snapshot snapshot1 = snapshots.create(node1, tester.clock().instant()); + assertEquals(SecretVersionId.of("2"), snapshot1.key().get().sealingKeyVersion()); + } + +} diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/CapacityCheckerTester.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/CapacityCheckerTester.java index 9090716cf571..eb60e9cec62b 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/CapacityCheckerTester.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/CapacityCheckerTester.java @@ -33,6 +33,7 @@ import com.yahoo.vespa.hosted.provision.provisioning.FlavorConfigBuilder; import com.yahoo.vespa.hosted.provision.testutils.MockNameResolver; import com.yahoo.vespa.hosted.provision.testutils.OrchestratorMock; +import com.yahoo.vespa.hosted.provision.testutils.SecretStoreMock; import java.io.IOException; import java.nio.file.Files; @@ -76,7 +77,8 @@ public class CapacityCheckerTester { new MemoryMetricsDb(clock), new OrchestratorMock(), true, - 0); + 0, + new SecretStoreMock()); } private void updateCapacityChecker() { diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/SpareCapacityMaintainerTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/SpareCapacityMaintainerTest.java index d076a4222dc1..1407bcb4b1a5 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/SpareCapacityMaintainerTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/maintenance/SpareCapacityMaintainerTest.java @@ -2,19 +2,17 @@ package com.yahoo.vespa.hosted.provision.maintenance; import com.yahoo.config.provision.ApplicationId; +import com.yahoo.config.provision.ApplicationMutex; import com.yahoo.config.provision.ApplicationTransaction; import com.yahoo.config.provision.ClusterMembership; import com.yahoo.config.provision.ClusterSpec; import com.yahoo.config.provision.DockerImage; import com.yahoo.config.provision.Environment; -import com.yahoo.config.provision.Exclusivity; import com.yahoo.config.provision.Flavor; import com.yahoo.config.provision.NodeFlavors; import com.yahoo.config.provision.NodeResources; import com.yahoo.config.provision.NodeType; -import com.yahoo.config.provision.ApplicationMutex; import com.yahoo.config.provision.RegionName; -import com.yahoo.config.provision.SharedHosts; import com.yahoo.config.provision.Zone; import com.yahoo.test.ManualClock; import com.yahoo.transaction.NestedTransaction; @@ -30,6 +28,7 @@ import com.yahoo.vespa.hosted.provision.testutils.MockDeployer; import com.yahoo.vespa.hosted.provision.testutils.MockNameResolver; import com.yahoo.vespa.hosted.provision.testutils.OrchestratorMock; +import com.yahoo.vespa.hosted.provision.testutils.SecretStoreMock; import org.junit.Ignore; import org.junit.Test; @@ -273,7 +272,8 @@ private SpareCapacityMaintainerTester(int maxIterations) { new MemoryMetricsDb(clock), new OrchestratorMock(), true, - 1); + 1, + new SecretStoreMock()); deployer = new MockDeployer(nodeRepository); maintainer = new SpareCapacityMaintainer(deployer, nodeRepository, metric, Duration.ofDays(1), maxIterations); } diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/SnapshotSerializerTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/SnapshotSerializerTest.java index aeec55033842..e4574af18f72 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/SnapshotSerializerTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/persistence/SnapshotSerializerTest.java @@ -1,16 +1,24 @@ package com.yahoo.vespa.hosted.provision.persistence; +import ai.vespa.secret.model.SecretVersionId; import com.yahoo.config.provision.ApplicationId; import com.yahoo.config.provision.CloudAccount; import com.yahoo.config.provision.ClusterSpec; import com.yahoo.config.provision.HostName; -import com.yahoo.vespa.hosted.provision.backup.Snapshot; import com.yahoo.config.provision.SnapshotId; +import com.yahoo.security.KeyId; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.SecretSharedKey; +import com.yahoo.security.SharedKeyGenerator; +import com.yahoo.vespa.hosted.provision.backup.Snapshot; +import com.yahoo.vespa.hosted.provision.backup.SnapshotKey; import com.yahoo.vespa.hosted.provision.node.ClusterId; import org.junit.jupiter.api.Test; +import java.security.PublicKey; import java.time.Instant; import java.util.List; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -21,6 +29,9 @@ class SnapshotSerializerTest { @Test void serialization() { + PublicKey publicKey = KeyUtils.generateX25519KeyPair().getPublic(); + SecretSharedKey sharedKey = SharedKeyGenerator.generateForReceiverPublicKey(publicKey, + KeyId.ofString("mykey")); CloudAccount systemAccount = CloudAccount.from("aws:999123456789"); Snapshot snapshot0 = new Snapshot(SnapshotId.of("ccf0b6de-3e06-4045-acba-458d99ef73e5"), HostName.of("host0.example.com"), @@ -29,7 +40,8 @@ void serialization() { new ClusterId(ApplicationId.from("t1", "a1", "i1"), ClusterSpec.Id.from("c1")), 0, - CloudAccount.from("aws:000123456789") + CloudAccount.from("aws:000123456789"), + Optional.empty() ); Snapshot snapshot1 = new Snapshot(SnapshotId.of("7e45b44a-0f1a-4729-a4f4-20fff5d1e85d"), HostName.of("host1.example.com"), @@ -39,7 +51,8 @@ void serialization() { new ClusterId(ApplicationId.from("t2", "a2", "i2"), ClusterSpec.Id.from("c2")), 2, - CloudAccount.from("aws:777123456789") + CloudAccount.from("aws:777123456789"), + Optional.of(new SnapshotKey(sharedKey.sealedSharedKey(), SecretVersionId.of("v1"))) ); assertEquals(snapshot0, SnapshotSerializer.fromSlime(SnapshotSerializer.toSlime(snapshot0), systemAccount)); List snapshots = List.of(snapshot0, snapshot1); diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/provisioning/ProvisioningTester.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/provisioning/ProvisioningTester.java index 35ad4d5cafcd..4239e21e01a9 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/provisioning/ProvisioningTester.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/provisioning/ProvisioningTester.java @@ -1,6 +1,9 @@ // Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.provisioning; +import ai.vespa.secret.model.Key; +import ai.vespa.secret.model.Secret; +import ai.vespa.secret.model.SecretVersionId; import com.yahoo.component.Version; import com.yahoo.config.provision.ActivationContext; import com.yahoo.config.provision.ApplicationId; @@ -30,6 +33,7 @@ import com.yahoo.config.provision.Zone; import com.yahoo.config.provisioning.FlavorsConfig; import com.yahoo.jdisc.test.MockMetric; +import com.yahoo.security.KeyUtils; import com.yahoo.test.ManualClock; import com.yahoo.transaction.NestedTransaction; import com.yahoo.vespa.applicationmodel.InfrastructureApplication; @@ -54,6 +58,7 @@ import com.yahoo.vespa.hosted.provision.testutils.MockNameResolver; import com.yahoo.vespa.hosted.provision.testutils.MockProvisionServiceProvider; import com.yahoo.vespa.hosted.provision.testutils.OrchestratorMock; +import com.yahoo.vespa.hosted.provision.testutils.SecretStoreMock; import com.yahoo.vespa.orchestrator.Orchestrator; import com.yahoo.vespa.service.duper.ConfigServerApplication; import com.yahoo.vespa.service.duper.ConfigServerHostApplication; @@ -63,6 +68,8 @@ import com.yahoo.vespa.service.duper.ProxyHostApplication; import com.yahoo.vespa.service.duper.TenantHostApplication; +import java.security.KeyPair; +import java.security.interfaces.XECPrivateKey; import java.time.temporal.TemporalAmount; import java.util.ArrayList; import java.util.Collection; @@ -97,6 +104,7 @@ public class ProvisioningTester { private final InMemoryProvisionLogger provisionLogger; private final LoadBalancerServiceMock loadBalancerService; private final SnapshotStoreMock snapshotStore; + private final SecretStoreMock secretStore; private int nextHost = 0; private int nextIP = 0; @@ -112,7 +120,8 @@ private ProvisioningTester(Curator curator, LoadBalancerServiceMock loadBalancerService, FlagSource flagSource, int spareCount, - ManualClock clock) { + ManualClock clock, + SecretStoreMock secretStore) { this.curator = curator; this.nodeFlavors = nodeFlavors; this.clock = clock; @@ -132,10 +141,12 @@ private ProvisioningTester(Curator curator, new MemoryMetricsDb(clock), orchestrator, true, - spareCount); + spareCount, + secretStore); this.provisioner = new NodeRepositoryProvisioner(nodeRepository, zone, provisionServiceProvider, new MockMetric()); this.provisionLogger = new InMemoryProvisionLogger(); this.loadBalancerService = loadBalancerService; + this.secretStore = secretStore; } public static FlavorsConfig createConfig() { @@ -158,6 +169,7 @@ public Curator getCurator() { public NodeRepository nodeRepository() { return nodeRepository; } public Orchestrator orchestrator() { return nodeRepository.orchestrator(); } public ManualClock clock() { return clock; } + public SecretStoreMock secretStore() { return secretStore; } public NodeRepositoryProvisioner provisioner() { return provisioner; } public HostProvisioner hostProvisioner() { return hostProvisioner; } public LoadBalancerServiceMock loadBalancerService() { return loadBalancerService; } @@ -665,6 +677,7 @@ public static final class Builder { private int spareCount = 0; private ManualClock clock = new ManualClock(); private DockerImage defaultImage = DockerImage.fromString("docker-registry.domain.tld:8080/dist/vespa"); + private SecretStoreMock secretStore; public Builder curator(Curator curator) { this.curator = curator; @@ -746,23 +759,41 @@ public Builder clock(ManualClock clock) { return this; } + public Builder secretStore(SecretStoreMock secretStore) { + this.secretStore = secretStore; + return this; + } + private FlagSource defaultFlagSource() { return new InMemoryFlagSource(); } + private SecretStoreMock defaultSecretStore() { + SecretStoreMock secretStore = new SecretStoreMock(); + KeyPair keyPair = KeyUtils.generateX25519KeyPair(); + secretStore.add(new Secret(Key.fromString("snapshot/sealingPrivateKey"), + KeyUtils.toBase64EncodedX25519PrivateKey((XECPrivateKey) keyPair.getPrivate()) + .getBytes(), + SecretVersionId.of("1"))); + return secretStore; + } + public ProvisioningTester build() { return new ProvisioningTester(Optional.ofNullable(curator).orElseGet(MockCurator::new), new NodeFlavors(Optional.ofNullable(flavorsConfig).orElseGet(ProvisioningTester::createConfig)), resourcesCalculator, - Optional.ofNullable(zone).orElseGet(Zone::defaultZone), + Optional.ofNullable(zone).orElseGet(() -> new Zone(Cloud.builder().snapshotPrivateKeySecretName("snapshot/sealingPrivateKey").build(), + SystemName.defaultSystem(), Environment.defaultEnvironment(), + RegionName.defaultName())), Optional.ofNullable(nameResolver).orElseGet(() -> new MockNameResolver().mockAnyLookup()), defaultImage, Optional.ofNullable(orchestrator).orElseGet(() -> new OrchestratorMock(clock)), hostProvisioner, new LoadBalancerServiceMock(), - Optional.ofNullable(flagSource).orElse(defaultFlagSource()), + Optional.ofNullable(flagSource).orElseGet(this::defaultFlagSource), spareCount, - clock); + clock, + Optional.ofNullable(secretStore).orElseGet(this::defaultSecretStore)); } private static FlavorsConfig asConfig(List flavors) { diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiTest.java b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiTest.java index c73490fbe060..04e03dd60d45 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiTest.java +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/NodesV2ApiTest.java @@ -1,6 +1,9 @@ // Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. package com.yahoo.vespa.hosted.provision.restapi; +import ai.vespa.secret.model.Key; +import ai.vespa.secret.model.Secret; +import ai.vespa.secret.model.SecretVersionId; import com.yahoo.application.container.handler.Request; import com.yahoo.application.container.handler.Response; import com.yahoo.config.provision.ApplicationId; @@ -8,6 +11,7 @@ import com.yahoo.config.provision.NodeType; import com.yahoo.config.provision.SystemName; import com.yahoo.config.provision.TenantName; +import com.yahoo.security.KeyUtils; import com.yahoo.slime.SlimeUtils; import com.yahoo.text.Utf8; import com.yahoo.vespa.applicationmodel.HostName; @@ -16,12 +20,16 @@ import com.yahoo.vespa.hosted.provision.maintenance.TestMetric; import com.yahoo.vespa.hosted.provision.testutils.MockNodeRepository; import com.yahoo.vespa.hosted.provision.testutils.OrchestratorMock; +import com.yahoo.vespa.hosted.provision.testutils.SecretStoreMock; import org.junit.After; import org.junit.Before; import org.junit.Test; import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.interfaces.XECPrivateKey; +import java.security.interfaces.XECPublicKey; import java.time.Duration; import java.util.Arrays; import java.util.List; @@ -864,6 +872,14 @@ public void test_os_version() throws Exception { @Test public void test_snapshots() throws IOException { + SecretStoreMock secretStore = (SecretStoreMock) tester.container().components() + .getComponent(SecretStoreMock.class.getName()); + KeyPair keyPair = KeyUtils.generateX25519KeyPair(); + secretStore.add(new Secret(Key.fromString("snapshot/sealingPrivateKey"), + KeyUtils.toBase64EncodedX25519PrivateKey((XECPrivateKey) keyPair.getPrivate()) + .getBytes(), + SecretVersionId.of("1"))); + // Trigger creation of snapshots tester.assertResponseContains(new Request("http://localhost:8080/nodes/v2/snapshot/host4.yahoo.com", new byte[0], Request.Method.POST), @@ -900,6 +916,12 @@ public void test_snapshots() throws IOException { // Get node tester.assertFile(new Request("http://localhost:8080/nodes/v2/node/host4.yahoo.com"), "snapshot/node4.json"); + // Retrieve encryption key + String receiverPublicKey = KeyUtils.toBase64EncodedX25519PublicKey((XECPublicKey) KeyUtils.generateX25519KeyPair().getPublic()); + tester.assertResponseContains(new Request("http://localhost:8080/nodes/v2/snapshot/host4.yahoo.com/" + id0 + "/key", + "{\"sealingKey\":\"" + receiverPublicKey + "\"}", Request.Method.POST), + "{\"sealedSharedKey\""); + // Trigger another snapshot tester.assertResponseContains(new Request("http://localhost:8080/nodes/v2/snapshot/host4.yahoo.com", new byte[0], Request.Method.POST), diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list-host-updated.json b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list-host-updated.json index 02e9e8f9adac..4c3b008a563a 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list-host-updated.json +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list-host-updated.json @@ -7,6 +7,8 @@ "hostname": "host4.yahoo.com", "id": "(ignore)", "instance": "tenant3:application3:instance3", + "sealedSharedKey": "(ignore)", + "sealingKeyVersion": "1", "state": "created", "history": [ { diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list-host.json b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list-host.json index cbac8f26eed6..c3cfe2bf314f 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list-host.json +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list-host.json @@ -7,6 +7,8 @@ "hostname": "host4.yahoo.com", "id": "(ignore)", "instance": "tenant3:application3:instance3", + "sealedSharedKey": "(ignore)", + "sealingKeyVersion": "1", "state": "creating", "history": [ { diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list.json b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list.json index 2dac2282177e..3bff669c4c10 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list.json +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/list.json @@ -7,6 +7,8 @@ "hostname": "host2.yahoo.com", "id": "(ignore)", "instance": "tenant2:application2:instance2", + "sealedSharedKey": "(ignore)", + "sealingKeyVersion": "1", "state": "creating", "history": [ { @@ -22,6 +24,8 @@ "hostname": "host4.yahoo.com", "id": "(ignore)", "instance": "tenant3:application3:instance3", + "sealedSharedKey": "(ignore)", + "sealingKeyVersion": "1", "state": "creating", "history": [ { diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/node4.json b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/node4.json index 8cd604f3ed3e..bc66cac010da 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/node4.json +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/node4.json @@ -123,6 +123,7 @@ "cloudAccount": "aws:111222333444", "snapshot": { "id": "(ignore)", - "state": "created" + "state": "created", + "encrypted": true } } diff --git a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/single.json b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/single.json index 243548e9a19f..26d1368aaa35 100644 --- a/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/single.json +++ b/node-repository/src/test/java/com/yahoo/vespa/hosted/provision/restapi/responses/snapshot/single.json @@ -5,6 +5,8 @@ "hostname": "host4.yahoo.com", "id": "(ignore)", "instance": "tenant3:application3:instance3", + "sealedSharedKey": "(ignore)", + "sealingKeyVersion": "1", "state": "creating", "history": [ {