diff options
author | Morten Tokle <mortent@vespa.ai> | 2024-06-12 14:50:44 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-06-12 14:50:44 +0200 |
commit | e96c6abd60522fabd39e6e0d893b8a13563a8813 (patch) | |
tree | a49af7a8430a10d4461b38264e40a88495696ebf | |
parent | 9e03e5ce71b178001e67dc59b50aa595c3fb0268 (diff) | |
parent | 9088e74de543dca845b85da9bdc46b758a58700d (diff) |
Merge pull request #31548 from vespa-engine/revert-31542-mortent/remove-legacy-iddoc-version
Revert "Remove legacy document version support"
13 files changed, 1000 insertions, 6 deletions
diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapper.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapper.java index ac620d2f6d4..4bbdd24db32 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapper.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapper.java @@ -4,9 +4,11 @@ package com.yahoo.vespa.athenz.identityprovider.api; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.yahoo.vespa.athenz.api.AthenzIdentity; import com.yahoo.vespa.athenz.api.AthenzService; import com.yahoo.vespa.athenz.identityprovider.api.bindings.DefaultSignedIdentityDocumentEntity; import com.yahoo.vespa.athenz.identityprovider.api.bindings.IdentityDocumentEntity; +import com.yahoo.vespa.athenz.identityprovider.api.bindings.LegacySignedIdentityDocumentEntity; import com.yahoo.vespa.athenz.identityprovider.api.bindings.SignedIdentityDocumentEntity; import com.yahoo.vespa.athenz.utils.AthenzIdentities; import com.yahoo.yolean.Exceptions; @@ -52,7 +54,25 @@ public class EntityBindingsMapper { } public static SignedIdentityDocument toSignedIdentityDocument(SignedIdentityDocumentEntity entity) { - if (entity instanceof DefaultSignedIdentityDocumentEntity docEntity) { + if (entity instanceof LegacySignedIdentityDocumentEntity docEntity) { + IdentityDocument doc = new IdentityDocument( + fromDottedString(docEntity.providerUniqueId()), + new AthenzService(docEntity.providerService()), + docEntity.configServerHostname(), + docEntity.instanceHostname(), + docEntity.createdAt(), + docEntity.ipAddresses(), + IdentityType.fromId(docEntity.identityType()), + Optional.ofNullable(docEntity.clusterType()).map(ClusterType::from).orElse(null), + docEntity.ztsUrl(), + Optional.ofNullable(docEntity.serviceIdentity()).map(AthenzIdentities::from).orElse(null), + docEntity.unknownAttributes()); + return new LegacySignedIdentityDocument( + docEntity.signature(), + docEntity.signingKeyVersion(), + entity.documentVersion(), + doc); + } else if (entity instanceof DefaultSignedIdentityDocumentEntity docEntity) { return new DefaultSignedIdentityDocument(docEntity.signature(), docEntity.signingKeyVersion(), docEntity.documentVersion(), @@ -63,7 +83,24 @@ public class EntityBindingsMapper { } public static SignedIdentityDocumentEntity toSignedIdentityDocumentEntity(SignedIdentityDocument model) { - if (model instanceof DefaultSignedIdentityDocument defaultModel){ + if (model instanceof LegacySignedIdentityDocument legacyModel) { + IdentityDocument idDoc = legacyModel.identityDocument(); + return new LegacySignedIdentityDocumentEntity( + legacyModel.signature(), + legacyModel.signingKeyVersion(), + idDoc.providerUniqueId().asDottedString(), + idDoc.providerService().getFullName(), + legacyModel.documentVersion(), + idDoc.configServerHostname(), + idDoc.instanceHostname(), + idDoc.createdAt(), + idDoc.ipAddresses(), + idDoc.identityType().id(), + Optional.ofNullable(idDoc.clusterType()).map(ClusterType::toConfigValue).orElse(null), + idDoc.ztsUrl(), + Optional.ofNullable(idDoc.serviceIdentity()).map(AthenzIdentity::getFullName).orElse(null), + idDoc.unknownAttributes()); + } else if (model instanceof DefaultSignedIdentityDocument defaultModel){ return new DefaultSignedIdentityDocumentEntity(defaultModel.signature(), defaultModel.signingKeyVersion(), defaultModel.documentVersion(), diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/LegacySignedIdentityDocument.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/LegacySignedIdentityDocument.java new file mode 100644 index 00000000000..dfab93b2a28 --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/LegacySignedIdentityDocument.java @@ -0,0 +1,6 @@ +// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.identityprovider.api; + +public record LegacySignedIdentityDocument(String signature, int signingKeyVersion, int documentVersion, + IdentityDocument identityDocument) implements SignedIdentityDocument { +} diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/SignedIdentityDocument.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/SignedIdentityDocument.java index 39629d878db..8ab07d97e74 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/SignedIdentityDocument.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/SignedIdentityDocument.java @@ -8,9 +8,10 @@ package com.yahoo.vespa.athenz.identityprovider.api; */ public interface SignedIdentityDocument { + int LEGACY_DEFAULT_DOCUMENT_VERSION = 3; int DEFAULT_DOCUMENT_VERSION = 4; - default boolean outdated() { return documentVersion() < DEFAULT_DOCUMENT_VERSION; } + default boolean outdated() { return documentVersion() < LEGACY_DEFAULT_DOCUMENT_VERSION; } IdentityDocument identityDocument(); String signature(); diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/LegacySignedIdentityDocumentEntity.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/LegacySignedIdentityDocumentEntity.java new file mode 100644 index 00000000000..647ca474420 --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/LegacySignedIdentityDocumentEntity.java @@ -0,0 +1,58 @@ +// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.identityprovider.api.bindings; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.net.URI; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * @author bjorncs + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record LegacySignedIdentityDocumentEntity ( + String signature, int signingKeyVersion, String providerUniqueId, String providerService, int documentVersion, + String configServerHostname, String instanceHostname, Instant createdAt, Set<String> ipAddresses, + String identityType, String clusterType, URI ztsUrl, String serviceIdentity, Map<String, Object> unknownAttributes) implements SignedIdentityDocumentEntity { + + @JsonCreator + public LegacySignedIdentityDocumentEntity(@JsonProperty("signature") String signature, + @JsonProperty("signing-key-version") int signingKeyVersion, + @JsonProperty("provider-unique-id") String providerUniqueId, + @JsonProperty("provider-service") String providerService, + @JsonProperty("document-version") int documentVersion, + @JsonProperty("configserver-hostname") String configServerHostname, + @JsonProperty("instance-hostname") String instanceHostname, + @JsonProperty("created-at") Instant createdAt, + @JsonProperty("ip-addresses") Set<String> ipAddresses, + @JsonProperty("identity-type") String identityType, + @JsonProperty("cluster-type") String clusterType, + @JsonProperty("zts-url") String ztsUrl, + @JsonProperty("service-identity") String serviceIdentity) { + this(signature, signingKeyVersion, providerUniqueId, providerService, documentVersion, configServerHostname, + instanceHostname, createdAt, ipAddresses, identityType, clusterType, URI.create(ztsUrl), serviceIdentity, new HashMap<>()); + } + + @JsonProperty("signature") @Override public String signature() { return signature; } + @JsonProperty("signing-key-version") @Override public int signingKeyVersion() { return signingKeyVersion; } + @JsonProperty("provider-unique-id") @Override public String providerUniqueId() { return providerUniqueId; } + @JsonProperty("provider-service") @Override public String providerService() { return providerService; } + @JsonProperty("document-version") @Override public int documentVersion() { return documentVersion; } + @JsonProperty("configserver-hostname") @Override public String configServerHostname() { return configServerHostname; } + @JsonProperty("instance-hostname") @Override public String instanceHostname() { return instanceHostname; } + @JsonProperty("created-at") @Override public Instant createdAt() { return createdAt; } + @JsonProperty("ip-addresses") @Override public Set<String> ipAddresses() { return ipAddresses; } + @JsonProperty("identity-type") @Override public String identityType() { return identityType; } + @JsonProperty("cluster-type") @Override public String clusterType() { return clusterType; } + @JsonProperty("zts-url") @Override public URI ztsUrl() { return ztsUrl; } + @JsonProperty("service-identity") @Override public String serviceIdentity() { return serviceIdentity; } + @JsonAnyGetter @Override public Map<String, Object> unknownAttributes() { return unknownAttributes; } + @JsonAnySetter public void set(String name, Object value) { unknownAttributes.put(name, value); } +} diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/SignedIdentityDocumentEntity.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/SignedIdentityDocumentEntity.java index d909849e9ce..c5c39fb6590 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/SignedIdentityDocumentEntity.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/api/bindings/SignedIdentityDocumentEntity.java @@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.annotation.JsonTypeIdResolver; import com.fasterxml.jackson.databind.jsontype.TypeIdResolver; import com.fasterxml.jackson.databind.type.TypeFactory; +import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; import java.io.IOException; import java.util.Objects; @@ -54,7 +55,10 @@ class SignedIdentityDocumentEntityTypeResolver implements TypeIdResolver { @Override public JavaType typeFromId(DatabindContext databindContext, String s) throws IOException { try { - Class<? extends SignedIdentityDocumentEntity> cls = DefaultSignedIdentityDocumentEntity.class; + int version = Integer.parseInt(s); + Class<? extends SignedIdentityDocumentEntity> cls = version <= SignedIdentityDocument.LEGACY_DEFAULT_DOCUMENT_VERSION + ? LegacySignedIdentityDocumentEntity.class + : DefaultSignedIdentityDocumentEntity.class; return TypeFactory.defaultInstance().constructSpecializedType(javaType,cls); } catch (NumberFormatException e) { throw new IllegalArgumentException("Unable to deserialize document with version: \"%s\"".formatted(s)); diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/AthenzCredentialsService.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/AthenzCredentialsService.java new file mode 100644 index 00000000000..63c966004e5 --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/AthenzCredentialsService.java @@ -0,0 +1,156 @@ +// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.identityprovider.client; + +import com.yahoo.container.core.identity.IdentityConfig; +import com.yahoo.security.KeyAlgorithm; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.Pkcs10Csr; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.client.zts.DefaultZtsClient; +import com.yahoo.vespa.athenz.client.zts.InstanceIdentity; +import com.yahoo.vespa.athenz.client.zts.ZtsClient; +import com.yahoo.vespa.athenz.identity.ServiceIdentityProvider; +import com.yahoo.vespa.athenz.identityprovider.api.EntityBindingsMapper; +import com.yahoo.vespa.athenz.identityprovider.api.IdentityDocument; +import com.yahoo.vespa.athenz.identityprovider.api.IdentityDocumentClient; +import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; +import com.yahoo.vespa.athenz.tls.AthenzIdentityVerifier; +import com.yahoo.vespa.athenz.utils.SiaUtils; +import com.yahoo.vespa.defaults.Defaults; + +import javax.net.ssl.SSLContext; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyPair; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.util.Optional; + +import static java.util.Collections.singleton; + +/** + * A service that provides method for initially registering the instance and refreshing it. + * + * @author bjorncs + */ +class AthenzCredentialsService { + private static final Duration EXPIRATION_MARGIN = Duration.ofDays(2); + private static final Path VESPA_SIA_DIRECTORY = Paths.get(Defaults.getDefaults().underVespaHome("var/vespa/sia")); + private static final Path IDENTITY_DOCUMENT_FILE = VESPA_SIA_DIRECTORY.resolve("vespa-tenant-identity-document.json"); + + private final AthenzService tenantIdentity; + private final URI configserverEndpoint; + private final URI ztsEndpoint; + private final AthenzService configserverIdentity; + private final ServiceIdentityProvider nodeIdentityProvider; + private final String hostname; + private final CsrGenerator csrGenerator; + private final Clock clock; + + AthenzCredentialsService(IdentityConfig identityConfig, + ServiceIdentityProvider nodeIdentityProvider, + String hostname, + Clock clock) { + this.tenantIdentity = new AthenzService(identityConfig.domain(), identityConfig.service()); + this.configserverEndpoint = URI.create("https://" + identityConfig.loadBalancerAddress() + ":4443"); + this.ztsEndpoint = URI.create(identityConfig.ztsUrl()); + this.configserverIdentity = new AthenzService(identityConfig.configserverIdentityName()); + this.nodeIdentityProvider = nodeIdentityProvider; + this.hostname = hostname; + this.csrGenerator = new CsrGenerator(identityConfig.athenzDnsSuffix(), identityConfig.configserverIdentityName()); + this.clock = clock; + } + + Path certificatePath() { return SiaUtils.getCertificateFile(VESPA_SIA_DIRECTORY, tenantIdentity); } + Path privateKeyPath() { return SiaUtils.getPrivateKeyFile(VESPA_SIA_DIRECTORY, tenantIdentity); } + + AthenzCredentials registerInstance() { + Optional<AthenzCredentials> athenzCredentialsFromDisk = tryReadCredentialsFromDisk(); + if (athenzCredentialsFromDisk.isPresent()) { + return athenzCredentialsFromDisk.get(); + } + KeyPair keyPair = KeyUtils.generateKeypair(KeyAlgorithm.RSA); + IdentityDocumentClient identityDocumentClient = createIdentityDocumentClient(); + // Use legacy version for now. + SignedIdentityDocument signedDocument = identityDocumentClient.getTenantIdentityDocument(hostname, SignedIdentityDocument.LEGACY_DEFAULT_DOCUMENT_VERSION).orElseThrow(); + IdentityDocument document = signedDocument.identityDocument(); + Pkcs10Csr csr = csrGenerator.generateInstanceCsr( + tenantIdentity, + document.providerUniqueId(), + document.ipAddresses(), + document.clusterType(), + keyPair); + + try (ZtsClient ztsClient = new DefaultZtsClient.Builder(ztsEndpoint).withIdentityProvider(nodeIdentityProvider).build()) { + InstanceIdentity instanceIdentity = + ztsClient.registerInstance( + configserverIdentity, + tenantIdentity, + EntityBindingsMapper.toAttestationData(signedDocument), + csr); + X509Certificate certificate = instanceIdentity.certificate(); + writeCredentialsToDisk(keyPair.getPrivate(), certificate, signedDocument); + return new AthenzCredentials(certificate, keyPair, signedDocument); + } + } + + AthenzCredentials updateCredentials(SignedIdentityDocument signedDocument, SSLContext sslContext) { + KeyPair newKeyPair = KeyUtils.generateKeypair(KeyAlgorithm.RSA); + IdentityDocument document = signedDocument.identityDocument(); + Pkcs10Csr csr = csrGenerator.generateInstanceCsr( + tenantIdentity, + document.providerUniqueId(), + document.ipAddresses(), + document.clusterType(), + newKeyPair); + + try (ZtsClient ztsClient = new DefaultZtsClient.Builder(ztsEndpoint).withSslContext(sslContext).build()) { + InstanceIdentity instanceIdentity = + ztsClient.refreshInstance( + configserverIdentity, + tenantIdentity, + document.providerUniqueId().asDottedString(), + csr); + X509Certificate certificate = instanceIdentity.certificate(); + writeCredentialsToDisk(newKeyPair.getPrivate(), certificate, signedDocument); + return new AthenzCredentials(certificate, newKeyPair, signedDocument); + } + } + + private Optional<AthenzCredentials> tryReadCredentialsFromDisk() { + Optional<PrivateKey> privateKey = SiaUtils.readPrivateKeyFile(VESPA_SIA_DIRECTORY, tenantIdentity); + if (privateKey.isEmpty()) return Optional.empty(); + Optional<X509Certificate> certificate = SiaUtils.readCertificateFile(VESPA_SIA_DIRECTORY, tenantIdentity); + if (certificate.isEmpty()) return Optional.empty(); + if (isExpired(certificate.get())) { + return Optional.empty(); + } + if (Files.notExists(IDENTITY_DOCUMENT_FILE)) return Optional.empty(); + SignedIdentityDocument signedIdentityDocument = EntityBindingsMapper.readSignedIdentityDocumentFromFile(IDENTITY_DOCUMENT_FILE); + KeyPair keyPair = new KeyPair(KeyUtils.extractPublicKey(privateKey.get()), privateKey.get()); + return Optional.of(new AthenzCredentials(certificate.get(), keyPair, signedIdentityDocument)); + } + + private boolean isExpired(X509Certificate certificate) { + return clock.instant().isAfter(certificate.getNotAfter().toInstant().minus(EXPIRATION_MARGIN)); + } + + private void writeCredentialsToDisk(PrivateKey privateKey, + X509Certificate certificate, + SignedIdentityDocument identityDocument) { + SiaUtils.writePrivateKeyFile(VESPA_SIA_DIRECTORY, tenantIdentity, privateKey); + SiaUtils.writeCertificateFile(VESPA_SIA_DIRECTORY, tenantIdentity, certificate); + EntityBindingsMapper.writeSignedIdentityDocumentToFile(IDENTITY_DOCUMENT_FILE, identityDocument); + } + + private DefaultIdentityDocumentClient createIdentityDocumentClient() { + return new DefaultIdentityDocumentClient( + configserverEndpoint, + nodeIdentityProvider, + new AthenzIdentityVerifier(singleton(configserverIdentity))); + } +} diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/AthenzIdentityProviderProvider.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/AthenzIdentityProviderProvider.java index dec919cada0..30ae4fff59d 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/AthenzIdentityProviderProvider.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/AthenzIdentityProviderProvider.java @@ -7,17 +7,24 @@ import com.yahoo.container.jdisc.athenz.AthenzIdentityProvider; import com.yahoo.jdisc.Metric; import javax.inject.Inject; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; /** * @author olaa */ public class AthenzIdentityProviderProvider implements Provider<AthenzIdentityProvider> { + private final Path NODE_ADMIN_MANAGED_IDENTITY_DOCUMENT = Paths.get("/var/lib/sia/vespa-tenant-identity-document.json"); private final AthenzIdentityProvider athenzIdentityProvider; @Inject public AthenzIdentityProviderProvider(IdentityConfig config, Metric metric) { - athenzIdentityProvider = new AthenzIdentityProviderImpl(config, metric); + if (Files.exists(NODE_ADMIN_MANAGED_IDENTITY_DOCUMENT)) + athenzIdentityProvider = new AthenzIdentityProviderImpl(config, metric); + else + athenzIdentityProvider = new LegacyAthenzIdentityProviderImpl(config, metric); } @Override diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSigner.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSigner.java index 43f32a3bae7..fd2cefbc93e 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSigner.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSigner.java @@ -3,9 +3,11 @@ package com.yahoo.vespa.athenz.identityprovider.client; import com.yahoo.security.SignatureUtils; import com.yahoo.vespa.athenz.api.AthenzIdentity; +import com.yahoo.vespa.athenz.api.AthenzService; import com.yahoo.vespa.athenz.identityprovider.api.DefaultSignedIdentityDocument; import com.yahoo.vespa.athenz.identityprovider.api.IdentityDocument; import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; +import com.yahoo.vespa.athenz.identityprovider.api.LegacySignedIdentityDocument; import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; @@ -20,6 +22,7 @@ import java.util.Base64; import java.util.Set; import java.util.TreeSet; +import static com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument.LEGACY_DEFAULT_DOCUMENT_VERSION; import static java.nio.charset.StandardCharsets.UTF_8; /** @@ -41,8 +44,39 @@ public class IdentityDocumentSigner { } } + public String generateLegacySignature(IdentityDocument doc, PrivateKey privateKey) { + return generateSignature(doc.providerUniqueId(), doc.providerService(), doc.configServerHostname(), + doc.instanceHostname(), doc.createdAt(), doc.ipAddresses(), doc.identityType(), privateKey, doc.serviceIdentity()); + } + + // Cluster type is ignored due to old Vespa versions not forwarding unknown fields in signed identity document + private String generateSignature(VespaUniqueInstanceId providerUniqueId, + AthenzIdentity providerService, + String configServerHostname, + String instanceHostname, + Instant createdAt, + Set<String> ipAddresses, + IdentityType identityType, + PrivateKey privateKey, + AthenzIdentity serviceIdentity) { + try { + Signature signer = SignatureUtils.createSigner(privateKey); + signer.initSign(privateKey); + writeToSigner( + signer, providerUniqueId, providerService, configServerHostname, instanceHostname, createdAt, + ipAddresses, identityType); + writeToSigner(signer, serviceIdentity); + byte[] signature = signer.sign(); + return Base64.getEncoder().encodeToString(signature); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + public boolean hasValidSignature(SignedIdentityDocument doc, PublicKey publicKey) { - if (doc instanceof DefaultSignedIdentityDocument signedDoc) { + if (doc instanceof LegacySignedIdentityDocument signedDoc) { + return validateLegacySignature(signedDoc, publicKey); + } else if (doc instanceof DefaultSignedIdentityDocument signedDoc) { try { Signature signer = SignatureUtils.createVerifier(publicKey); signer.initVerify(publicKey); @@ -55,4 +89,46 @@ public class IdentityDocumentSigner { throw new IllegalArgumentException("Unknown identity document type: " + doc.getClass().getName()); } } + + private boolean validateLegacySignature(SignedIdentityDocument doc, PublicKey publicKey) { + try { + IdentityDocument iddoc = doc.identityDocument(); + Signature signer = SignatureUtils.createVerifier(publicKey); + signer.initVerify(publicKey); + writeToSigner( + signer, iddoc.providerUniqueId(), iddoc.providerService(), iddoc.configServerHostname(), + iddoc.instanceHostname(), iddoc.createdAt(), iddoc.ipAddresses(), iddoc.identityType()); + if (doc.documentVersion() >= LEGACY_DEFAULT_DOCUMENT_VERSION) { + writeToSigner(signer, iddoc.serviceIdentity()); + } + return signer.verify(Base64.getDecoder().decode(doc.signature())); + } catch (GeneralSecurityException e) { + throw new RuntimeException(e); + } + } + + private static void writeToSigner(Signature signer, + VespaUniqueInstanceId providerUniqueId, + AthenzIdentity providerService, + String configServerHostname, + String instanceHostname, + Instant createdAt, + Set<String> ipAddresses, + IdentityType identityType) throws SignatureException { + signer.update(providerUniqueId.asDottedString().getBytes(UTF_8)); + signer.update(providerService.getFullName().getBytes(UTF_8)); + signer.update(configServerHostname.getBytes(UTF_8)); + signer.update(instanceHostname.getBytes(UTF_8)); + ByteBuffer timestampAsBuffer = ByteBuffer.allocate(Long.BYTES); + timestampAsBuffer.putLong(createdAt.toEpochMilli()); + signer.update(timestampAsBuffer.array()); + for (String ipAddress : new TreeSet<>(ipAddresses)) { + signer.update(ipAddress.getBytes(UTF_8)); + } + signer.update(identityType.id().getBytes(UTF_8)); + } + + private static void writeToSigner(Signature signer, AthenzIdentity serviceIdentity) throws SignatureException{ + signer.update(serviceIdentity.getFullName().getBytes(UTF_8)); + } } diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/LegacyAthenzIdentityProviderImpl.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/LegacyAthenzIdentityProviderImpl.java new file mode 100644 index 00000000000..c00149e0e4b --- /dev/null +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/LegacyAthenzIdentityProviderImpl.java @@ -0,0 +1,397 @@ +// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.identityprovider.client; + +import ai.vespa.metrics.ContainerMetrics; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.CacheLoader; +import com.google.common.cache.LoadingCache; +import com.yahoo.component.AbstractComponent; +import com.yahoo.component.annotation.Inject; +import com.yahoo.container.core.identity.IdentityConfig; +import com.yahoo.container.jdisc.athenz.AthenzIdentityProvider; +import com.yahoo.container.jdisc.athenz.AthenzIdentityProviderException; +import com.yahoo.jdisc.Metric; +import com.yahoo.security.KeyStoreBuilder; +import com.yahoo.security.MutableX509KeyManager; +import com.yahoo.security.Pkcs10Csr; +import com.yahoo.security.SslContextBuilder; +import com.yahoo.security.X509CertificateWithKey; +import com.yahoo.vespa.athenz.api.AthenzAccessToken; +import com.yahoo.vespa.athenz.api.AthenzDomain; +import com.yahoo.vespa.athenz.api.AthenzRole; +import com.yahoo.vespa.athenz.api.AthenzService; +import com.yahoo.vespa.athenz.api.ZToken; +import com.yahoo.vespa.athenz.client.zts.DefaultZtsClient; +import com.yahoo.vespa.athenz.client.zts.ZtsClient; +import com.yahoo.vespa.athenz.identity.ServiceIdentityProvider; +import com.yahoo.vespa.athenz.identity.SiaIdentityProvider; +import com.yahoo.vespa.athenz.utils.SiaUtils; +import com.yahoo.vespa.defaults.Defaults; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.X509ExtendedKeyManager; +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.PrivateKey; +import java.security.cert.X509Certificate; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static com.yahoo.security.KeyStoreType.PKCS12; + +/** + * A {@link AthenzIdentityProvider} / {@link ServiceIdentityProvider} component that provides the tenant identity. + * + * @author mortent + * @author bjorncs + */ +// This class should probably not implement ServiceIdentityProvider, +// as that interface is intended for providing the node's identity, not the tenant's application identity. +public final class LegacyAthenzIdentityProviderImpl extends AbstractComponent implements AthenzIdentityProvider, ServiceIdentityProvider { + + private static final Logger log = Logger.getLogger(LegacyAthenzIdentityProviderImpl.class.getName()); + + // TODO Make some of these values configurable through config. Match requested expiration of register/update requests. + // TODO These should match the requested expiration + static final Duration UPDATE_PERIOD = Duration.ofDays(1); + static final Duration AWAIT_TERMINTATION_TIMEOUT = Duration.ofSeconds(90); + private final static Duration ROLE_SSL_CONTEXT_EXPIRY = Duration.ofHours(2); + // TODO CMS expects 10min or less token ttl. Use 10min default until we have configurable expiry + private final static Duration ROLE_TOKEN_EXPIRY = Duration.ofMinutes(10); + + // TODO Make path to trust store paths config + private static final Path CLIENT_TRUST_STORE = Paths.get("/opt/yahoo/share/ssl/certs/yahoo_certificate_bundle.pem"); + private static final Path ATHENZ_TRUST_STORE = Paths.get("/opt/yahoo/share/ssl/certs/athenz_certificate_bundle.pem"); + + public static final String CERTIFICATE_EXPIRY_METRIC_NAME = ContainerMetrics.ATHENZ_TENANT_CERT_EXPIRY_SECONDS.baseName(); + + private volatile AthenzCredentials credentials; + private final Metric metric; + private final Path trustStore; + private final AthenzCredentialsService athenzCredentialsService; + private final ScheduledExecutorService scheduler; + private final Clock clock; + private final AthenzService identity; + private final URI ztsEndpoint; + + private final MutableX509KeyManager identityKeyManager = new MutableX509KeyManager(); + private final SSLContext identitySslContext; + private final LoadingCache<AthenzRole, X509Certificate> roleSslCertCache; + private final Map<AthenzRole, MutableX509KeyManager> roleKeyManagerCache; + private final LoadingCache<AthenzRole, ZToken> roleSpecificRoleTokenCache; + private final LoadingCache<AthenzDomain, ZToken> domainSpecificRoleTokenCache; + private final LoadingCache<AthenzDomain, AthenzAccessToken> domainSpecificAccessTokenCache; + private final LoadingCache<List<AthenzRole>, AthenzAccessToken> roleSpecificAccessTokenCache; + private final CsrGenerator csrGenerator; + + @Inject + public LegacyAthenzIdentityProviderImpl(IdentityConfig config, Metric metric) { + this(config, + metric, + CLIENT_TRUST_STORE, + new AthenzCredentialsService(config, + createNodeIdentityProvider(config), + Defaults.getDefaults().vespaHostname(), + Clock.systemUTC()), + new ScheduledThreadPoolExecutor(1), + Clock.systemUTC()); + } + + // Test only + LegacyAthenzIdentityProviderImpl(IdentityConfig config, + Metric metric, + Path trustStore, + AthenzCredentialsService athenzCredentialsService, + ScheduledExecutorService scheduler, + Clock clock) { + this.metric = metric; + this.trustStore = trustStore; + this.athenzCredentialsService = athenzCredentialsService; + this.scheduler = scheduler; + this.clock = clock; + this.identity = new AthenzService(config.domain(), config.service()); + this.ztsEndpoint = URI.create(config.ztsUrl()); + roleSslCertCache = crateAutoReloadableCache(ROLE_SSL_CONTEXT_EXPIRY, this::requestRoleCertificate, this.scheduler); + roleKeyManagerCache = new HashMap<>(); + roleSpecificRoleTokenCache = createCache(ROLE_TOKEN_EXPIRY, this::createRoleToken); + domainSpecificRoleTokenCache = createCache(ROLE_TOKEN_EXPIRY, this::createRoleToken); + domainSpecificAccessTokenCache = createCache(ROLE_TOKEN_EXPIRY, this::createAccessToken); + roleSpecificAccessTokenCache = createCache(ROLE_TOKEN_EXPIRY, this::createAccessToken); + this.csrGenerator = new CsrGenerator(config.athenzDnsSuffix(), config.configserverIdentityName()); + this.identitySslContext = createIdentitySslContext(identityKeyManager, trustStore); + registerInstance(); + } + + private static <KEY, VALUE> LoadingCache<KEY, VALUE> createCache(Duration expiry, Function<KEY, VALUE> cacheLoader) { + return CacheBuilder.newBuilder() + .refreshAfterWrite(expiry.dividedBy(2).toMinutes(), TimeUnit.MINUTES) + .expireAfterWrite(expiry.toMinutes(), TimeUnit.MINUTES) + .build(new CacheLoader<KEY, VALUE>() { + @Override + public VALUE load(KEY key) { + return cacheLoader.apply(key); + } + }); + } + + private static <KEY, VALUE> LoadingCache<KEY, VALUE> crateAutoReloadableCache(Duration expiry, Function<KEY, VALUE> cacheLoader, ScheduledExecutorService scheduler) { + LoadingCache<KEY, VALUE> cache = createCache(expiry, cacheLoader); + + // The cache above will reload it's contents if and only if a request for the key is made. Scheduling + // a cache reloader to reload all keys in this cache. + scheduler.scheduleAtFixedRate(() -> { cache.asMap().keySet().forEach(cache::getUnchecked);}, + expiry.dividedBy(4).toMinutes(), + expiry.dividedBy(4).toMinutes(), + TimeUnit.MINUTES); + return cache; + } + + private static SSLContext createIdentitySslContext(X509ExtendedKeyManager keyManager, Path trustStore) { + return new SslContextBuilder() + .withKeyManager(keyManager) + .withTrustStore(trustStore) + .build(); + } + + private void registerInstance() { + try { + updateIdentityCredentials(this.athenzCredentialsService.registerInstance()); + this.scheduler.scheduleAtFixedRate(this::refreshCertificate, UPDATE_PERIOD.toMinutes(), UPDATE_PERIOD.toMinutes(), TimeUnit.MINUTES); + this.scheduler.scheduleAtFixedRate(this::reportMetrics, 0, 5, TimeUnit.MINUTES); + } catch (Throwable t) { + throw new AthenzIdentityProviderException("Could not retrieve Athenz credentials", t); + } + } + + @Override + public AthenzService identity() { + return identity; + } + + @Override + public String domain() { + return identity.getDomain().getName(); + } + + @Override + public String service() { + return identity.getName(); + } + + @Override + public SSLContext getIdentitySslContext() { + return identitySslContext; + } + + @Override + public X509CertificateWithKey getIdentityCertificateWithKey() { + AthenzCredentials copy = this.credentials; + return new X509CertificateWithKey(copy.getCertificate(), copy.getKeyPair().getPrivate()); + } + + @Override public Path certificatePath() { return athenzCredentialsService.certificatePath(); } + + @Override public Path privateKeyPath() { return athenzCredentialsService.privateKeyPath(); } + + @Override + public SSLContext getRoleSslContext(String domain, String role) { + try { + AthenzRole athenzRole = new AthenzRole(new AthenzDomain(domain), role); + // Make sure to request a certificate which triggers creating a new key manager for this role + X509Certificate x509Certificate = getRoleCertificate(athenzRole); + MutableX509KeyManager keyManager = roleKeyManagerCache.get(athenzRole); + return new SslContextBuilder() + .withKeyManager(keyManager) + .withTrustStore(trustStore) + .build(); + } catch (Exception e) { + throw new AthenzIdentityProviderException("Could not retrieve role certificate: " + e.getMessage(), e); + } + } + + @Override + public String getRoleToken(String domain) { + try { + return domainSpecificRoleTokenCache.get(new AthenzDomain(domain)).getRawToken(); + } catch (Exception e) { + throw new AthenzIdentityProviderException("Could not retrieve role token: " + e.getMessage(), e); + } + } + + @Override + public String getRoleToken(String domain, String role) { + try { + return roleSpecificRoleTokenCache.get(new AthenzRole(domain, role)).getRawToken(); + } catch (Exception e) { + throw new AthenzIdentityProviderException("Could not retrieve role token: " + e.getMessage(), e); + } + } + + @Override + public String getAccessToken(String domain) { + try { + return domainSpecificAccessTokenCache.get(new AthenzDomain(domain)).value(); + } catch (Exception e) { + throw new AthenzIdentityProviderException("Could not retrieve access token: " + e.getMessage(), e); + } + } + + @Override + public String getAccessToken(String domain, List<String> roles) { + try { + List<AthenzRole> roleList = roles.stream() + .map(roleName -> new AthenzRole(domain, roleName)) + .toList(); + return roleSpecificAccessTokenCache.get(roleList).value(); + } catch (Exception e) { + throw new AthenzIdentityProviderException("Could not retrieve access token: " + e.getMessage(), e); + } + } + + @Override + public String getAccessToken(String domain, List<String> roles, List<String> proxyPrincipal) { + throw new UnsupportedOperationException("Not implemented in legacy client"); + } + + @Override + public PrivateKey getPrivateKey() { + return credentials.getKeyPair().getPrivate(); + } + + @Override + public Path trustStorePath() { + return trustStore; + } + + @Override + public List<X509Certificate> getIdentityCertificate() { + return List.of(credentials.getCertificate()); + } + + @Override + public X509Certificate getRoleCertificate(String domain, String role) { + return getRoleCertificate(new AthenzRole(new AthenzDomain(domain), role)); + } + + private X509Certificate getRoleCertificate(AthenzRole athenzRole) { + try { + return roleSslCertCache.get(athenzRole); + } catch (Exception e) { + throw new AthenzIdentityProviderException("Could not retrieve role certificate: " + e.getMessage(), e); + } + } + + private void updateIdentityCredentials(AthenzCredentials credentials) { + this.credentials = credentials; + this.identityKeyManager.updateKeystore( + KeyStoreBuilder.withType(PKCS12) + .withKeyEntry("default", credentials.getKeyPair().getPrivate(), credentials.getCertificate()) + .build(), + new char[0]); + } + + private X509Certificate requestRoleCertificate(AthenzRole role) { + var doc = credentials.getIdentityDocument().identityDocument(); + Pkcs10Csr csr = csrGenerator.generateRoleCsr( + identity, role, doc.providerUniqueId(), doc.clusterType(), credentials.getKeyPair()); + try (ZtsClient client = createZtsClient()) { + X509Certificate roleCertificate = client.getRoleCertificate(role, csr); + updateRoleKeyManager(role, roleCertificate); + log.info(String.format("Requester role certificate for role %s, expires: %s", role.toResourceNameString(), roleCertificate.getNotAfter().toInstant().toString())); + return roleCertificate; + } + } + + private void updateRoleKeyManager(AthenzRole role, X509Certificate certificate) { + MutableX509KeyManager keyManager = roleKeyManagerCache.computeIfAbsent(role, r -> new MutableX509KeyManager()); + keyManager.updateKeystore( + KeyStoreBuilder.withType(PKCS12) + .withKeyEntry("default", credentials.getKeyPair().getPrivate(), certificate) + .build(), + new char[0]); + } + + private ZToken createRoleToken(AthenzRole athenzRole) { + try (ZtsClient client = createZtsClient()) { + return client.getRoleToken(athenzRole, ROLE_TOKEN_EXPIRY); + } + } + + private ZToken createRoleToken(AthenzDomain domain) { + try (ZtsClient client = createZtsClient()) { + return client.getRoleToken(domain, ROLE_TOKEN_EXPIRY); + } + } + + private AthenzAccessToken createAccessToken(AthenzDomain domain) { + try (ZtsClient client = createZtsClient()) { + return client.getAccessToken(domain); + } + } + + private AthenzAccessToken createAccessToken(List<AthenzRole> roles) { + try (ZtsClient client = createZtsClient()) { + return client.getAccessToken(roles); + } + } + + private DefaultZtsClient createZtsClient() { + return new DefaultZtsClient.Builder(ztsEndpoint).withSslContext(getIdentitySslContext()).build(); + } + + @Override + public void deconstruct() { + try { + scheduler.shutdownNow(); + scheduler.awaitTermination(AWAIT_TERMINTATION_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + private static SiaIdentityProvider createNodeIdentityProvider(IdentityConfig config) { + return new SiaIdentityProvider( + new AthenzService(config.nodeIdentityName()), SiaUtils.DEFAULT_SIA_DIRECTORY, CLIENT_TRUST_STORE); + } + + private boolean isExpired(AthenzCredentials credentials) { + return clock.instant().isAfter(getExpirationTime(credentials)); + } + + private static Instant getExpirationTime(AthenzCredentials credentials) { + return credentials.getCertificate().getNotAfter().toInstant(); + } + + void refreshCertificate() { + try { + updateIdentityCredentials(isExpired(credentials) + ? athenzCredentialsService.registerInstance() + : athenzCredentialsService.updateCredentials(credentials.getIdentityDocument(), identitySslContext)); + } catch (Throwable t) { + log.log(Level.WARNING, "Failed to update credentials: " + t.getMessage(), t); + } + } + + void reportMetrics() { + try { + Instant expirationTime = getExpirationTime(credentials); + Duration remainingLifetime = Duration.between(clock.instant(), expirationTime); + Metric.Context dimensions = metric.createContext(Map.of("implementation", this.getClassName())); + metric.set(CERTIFICATE_EXPIRY_METRIC_NAME, remainingLifetime.getSeconds(), dimensions); + } catch (Throwable t) { + log.log(Level.WARNING, "Failed to update metrics: " + t.getMessage(), t); + } + } +} + diff --git a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/ServiceIdentityProviderProvider.java b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/ServiceIdentityProviderProvider.java index 5dd49206c6d..d0267d406ce 100644 --- a/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/ServiceIdentityProviderProvider.java +++ b/vespa-athenz/src/main/java/com/yahoo/vespa/athenz/identityprovider/client/ServiceIdentityProviderProvider.java @@ -22,6 +22,7 @@ public class ServiceIdentityProviderProvider implements Provider<ServiceIdentity @Override public ServiceIdentityProvider get() { if (athenzIdentityProvider instanceof AthenzIdentityProviderImpl impl) return impl; + if (athenzIdentityProvider instanceof LegacyAthenzIdentityProviderImpl legacyImpl) return legacyImpl; return null; } diff --git a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapperTest.java b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapperTest.java index 377aee22ab1..297f0c904d9 100644 --- a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapperTest.java +++ b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/api/EntityBindingsMapperTest.java @@ -7,8 +7,12 @@ import org.junit.jupiter.api.Test; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * @author bjorncs @@ -16,6 +20,38 @@ import static org.junit.jupiter.api.Assertions.assertEquals; class EntityBindingsMapperTest { @Test + public void legacy_persists_unknown_json_members() throws IOException { + var originalJson = + """ + { + "signature": "sig", + "signing-key-version": 0, + "provider-unique-id": "0.cluster.instance.app.tenant.us-west-1.test.node", + "provider-service": "domain.service", + "document-version": 2, + "configserver-hostname": "cfg", + "instance-hostname": "host", + "created-at": 12345.0, + "ip-addresses": [], + "identity-type": "node", + "cluster-type": "admin", + "zts-url": "https://zts.url/", + "unknown-string": "string-value", + "unknown-object": { "member-in-unknown-object": 123 } + } + """; + var entity = EntityBindingsMapper.fromString(originalJson); + assertInstanceOf(LegacySignedIdentityDocument.class, entity); + assertEquals(2, entity.identityDocument().unknownAttributes().size(), entity.identityDocument().unknownAttributes().toString()); + var json = EntityBindingsMapper.toAttestationData(entity); + + var expectedMemberInJson = "member-in-unknown-object"; + assertTrue(json.contains(expectedMemberInJson), + () -> "Expected JSON to contain '%s', but got \n'%s'".formatted(expectedMemberInJson, json)); + assertEquals(EntityBindingsMapper.mapper.readTree(originalJson), EntityBindingsMapper.mapper.readTree(json)); + } + + @Test public void reads_unknown_json_members() throws IOException { var iddoc = """ { diff --git a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSignerTest.java b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSignerTest.java index 3845d9db5b2..2532a394f4e 100644 --- a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSignerTest.java +++ b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/IdentityDocumentSignerTest.java @@ -10,6 +10,7 @@ import com.yahoo.vespa.athenz.identityprovider.api.DefaultSignedIdentityDocument import com.yahoo.vespa.athenz.identityprovider.api.EntityBindingsMapper; import com.yahoo.vespa.athenz.identityprovider.api.IdentityDocument; import com.yahoo.vespa.athenz.identityprovider.api.IdentityType; +import com.yahoo.vespa.athenz.identityprovider.api.LegacySignedIdentityDocument; import com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument; import com.yahoo.vespa.athenz.identityprovider.api.VespaUniqueInstanceId; import org.junit.jupiter.api.Test; @@ -22,6 +23,8 @@ import java.util.List; import static com.yahoo.vespa.athenz.identityprovider.api.IdentityType.TENANT; import static com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument.DEFAULT_DOCUMENT_VERSION; +import static com.yahoo.vespa.athenz.identityprovider.api.SignedIdentityDocument.LEGACY_DEFAULT_DOCUMENT_VERSION; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -44,6 +47,21 @@ public class IdentityDocumentSignerTest { private static final AthenzIdentity serviceIdentity = new AthenzService("vespa", "node"); @Test + void legacy_generates_and_validates_signature() { + IdentityDocumentSigner signer = new IdentityDocumentSigner(); + IdentityDocument identityDocument = new IdentityDocument( + id, providerService, configserverHostname, + instanceHostname, createdAt, ipAddresses, identityType, clusterType, ztsUrl, serviceIdentity); + String signature = + signer.generateLegacySignature(identityDocument, keyPair.getPrivate()); + + SignedIdentityDocument signedIdentityDocument = new LegacySignedIdentityDocument( + signature, KEY_VERSION, LEGACY_DEFAULT_DOCUMENT_VERSION, identityDocument); + + assertTrue(signer.hasValidSignature(signedIdentityDocument, keyPair.getPublic())); + } + + @Test void generates_and_validates_signature() { IdentityDocumentSigner signer = new IdentityDocumentSigner(); IdentityDocument identityDocument = new IdentityDocument( @@ -58,4 +76,41 @@ public class IdentityDocumentSignerTest { assertTrue(signer.hasValidSignature(signedIdentityDocument, keyPair.getPublic())); } + + @Test + void legacy_ignores_cluster_type_and_zts_url() { + IdentityDocumentSigner signer = new IdentityDocumentSigner(); + IdentityDocument identityDocument = new IdentityDocument( + id, providerService, configserverHostname, + instanceHostname, createdAt, ipAddresses, identityType, clusterType, ztsUrl, serviceIdentity); + IdentityDocument withoutIgnoredFields = new IdentityDocument( + id, providerService, configserverHostname, + instanceHostname, createdAt, ipAddresses, identityType, null, null, serviceIdentity); + + String signature = + signer.generateLegacySignature(identityDocument, keyPair.getPrivate()); + + var docWithoutIgnoredFields = new LegacySignedIdentityDocument( + signature, KEY_VERSION, LEGACY_DEFAULT_DOCUMENT_VERSION, withoutIgnoredFields); + var docWithIgnoredFields = new LegacySignedIdentityDocument( + signature, KEY_VERSION, LEGACY_DEFAULT_DOCUMENT_VERSION, identityDocument); + + assertTrue(signer.hasValidSignature(docWithoutIgnoredFields, keyPair.getPublic())); + assertEquals(docWithIgnoredFields.signature(), docWithoutIgnoredFields.signature()); + } + + @Test + void validates_signature_for_new_and_old_versions() { + IdentityDocumentSigner signer = new IdentityDocumentSigner(); + IdentityDocument identityDocument = new IdentityDocument( + id, providerService, configserverHostname, + instanceHostname, createdAt, ipAddresses, identityType, clusterType, ztsUrl, serviceIdentity); + String signature = + signer.generateLegacySignature(identityDocument, keyPair.getPrivate()); + + SignedIdentityDocument signedIdentityDocument = new LegacySignedIdentityDocument( + signature, KEY_VERSION, LEGACY_DEFAULT_DOCUMENT_VERSION, identityDocument); + + assertTrue(signer.hasValidSignature(signedIdentityDocument, keyPair.getPublic())); + } } diff --git a/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/LegacyAthenzIdentityProviderImplTest.java b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/LegacyAthenzIdentityProviderImplTest.java new file mode 100644 index 00000000000..90853ff7cfa --- /dev/null +++ b/vespa-athenz/src/test/java/com/yahoo/vespa/athenz/identityprovider/client/LegacyAthenzIdentityProviderImplTest.java @@ -0,0 +1,160 @@ +// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.athenz.identityprovider.client; + +import com.yahoo.container.core.identity.IdentityConfig; +import com.yahoo.container.jdisc.athenz.AthenzIdentityProviderException; +import com.yahoo.jdisc.Metric; +import com.yahoo.security.KeyAlgorithm; +import com.yahoo.security.KeyStoreBuilder; +import com.yahoo.security.KeyStoreType; +import com.yahoo.security.KeyStoreUtils; +import com.yahoo.security.KeyUtils; +import com.yahoo.security.Pkcs10Csr; +import com.yahoo.security.Pkcs10CsrBuilder; +import com.yahoo.security.SignatureAlgorithm; +import com.yahoo.security.X509CertificateBuilder; +import com.yahoo.test.ManualClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.security.auth.x500.X500Principal; + +import java.io.File; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * @author mortent + * @author bjorncs + */ +public class LegacyAthenzIdentityProviderImplTest { + + @TempDir + public File tempDir; + + public static final Duration certificateValidity = Duration.ofDays(30); + + private static final IdentityConfig IDENTITY_CONFIG = + new IdentityConfig(new IdentityConfig.Builder() + .service("tenantService") + .domain("tenantDomain") + .nodeIdentityName("vespa.tenant") + .configserverIdentityName("vespa.configserver") + .loadBalancerAddress("cfg") + .ztsUrl("https:localhost:4443/zts/v1") + .athenzDnsSuffix("dev-us-north-1.vespa.cloud")); + + private final KeyPair caKeypair = KeyUtils.generateKeypair(KeyAlgorithm.EC); + private Path trustStoreFile; + private X509Certificate caCertificate; + + @BeforeEach + public void createTrustStoreFile() throws IOException { + caCertificate = X509CertificateBuilder + .fromKeypair( + caKeypair, + new X500Principal("CN=mydummyca"), + Instant.EPOCH, + Instant.EPOCH.plus(10000, ChronoUnit.DAYS), + SignatureAlgorithm.SHA256_WITH_ECDSA, + BigInteger.ONE) + .build(); + trustStoreFile = File.createTempFile("junit", null, tempDir).toPath(); + KeyStoreUtils.writeKeyStoreToFile( + KeyStoreBuilder.withType(KeyStoreType.JKS) + .withKeyEntry("default", caKeypair.getPrivate(), caCertificate) + .build(), + trustStoreFile); + } + + @Test + void component_creation_fails_when_credentials_not_found() { + assertThrows(AthenzIdentityProviderException.class, () -> { + AthenzCredentialsService credentialService = mock(AthenzCredentialsService.class); + when(credentialService.registerInstance()) + .thenThrow(new RuntimeException("athenz unavailable")); + + new LegacyAthenzIdentityProviderImpl(IDENTITY_CONFIG, mock(Metric.class), trustStoreFile, credentialService, mock(ScheduledExecutorService.class), new ManualClock(Instant.EPOCH)); + }); + } + + @Test + void metrics_updated_on_refresh() { + ManualClock clock = new ManualClock(Instant.EPOCH); + Metric metric = mock(Metric.class); + + AthenzCredentialsService athenzCredentialsService = mock(AthenzCredentialsService.class); + + KeyPair keyPair = KeyUtils.generateKeypair(KeyAlgorithm.EC); + X509Certificate certificate = getCertificate(keyPair, getExpirationSupplier(clock)); + + when(athenzCredentialsService.registerInstance()) + .thenReturn(new AthenzCredentials(certificate, keyPair, null)); + + when(athenzCredentialsService.updateCredentials(any(), any())) + .thenThrow(new RuntimeException("#1")) + .thenThrow(new RuntimeException("#2")) + .thenReturn(new AthenzCredentials(certificate, keyPair, null)); + + LegacyAthenzIdentityProviderImpl identityProvider = + new LegacyAthenzIdentityProviderImpl(IDENTITY_CONFIG, metric, trustStoreFile, athenzCredentialsService, mock(ScheduledExecutorService.class), clock); + + identityProvider.reportMetrics(); + verify(metric).set(eq(LegacyAthenzIdentityProviderImpl.CERTIFICATE_EXPIRY_METRIC_NAME), eq(certificateValidity.getSeconds()), any()); + + // Advance 1 day, refresh fails, cert is 1 day old + clock.advance(Duration.ofDays(1)); + identityProvider.refreshCertificate(); + identityProvider.reportMetrics(); + verify(metric).set(eq(LegacyAthenzIdentityProviderImpl.CERTIFICATE_EXPIRY_METRIC_NAME), eq(certificateValidity.minus(Duration.ofDays(1)).getSeconds()), any()); + + // Advance 1 more day, refresh fails, cert is 2 days old + clock.advance(Duration.ofDays(1)); + identityProvider.refreshCertificate(); + identityProvider.reportMetrics(); + verify(metric).set(eq(LegacyAthenzIdentityProviderImpl.CERTIFICATE_EXPIRY_METRIC_NAME), eq(certificateValidity.minus(Duration.ofDays(2)).getSeconds()), any()); + + // Advance 1 more day, refresh succeds, cert is new + clock.advance(Duration.ofDays(1)); + identityProvider.refreshCertificate(); + identityProvider.reportMetrics(); + verify(metric).set(eq(LegacyAthenzIdentityProviderImpl.CERTIFICATE_EXPIRY_METRIC_NAME), eq(certificateValidity.getSeconds()), any()); + + } + + private Supplier<Date> getExpirationSupplier(ManualClock clock) { + return () -> new Date(clock.instant().plus(certificateValidity).toEpochMilli()); + } + + private X509Certificate getCertificate(KeyPair keyPair, Supplier<Date> expiry) { + Pkcs10Csr csr = Pkcs10CsrBuilder.fromKeypair(new X500Principal("CN=dummy"), keyPair, SignatureAlgorithm.SHA256_WITH_ECDSA) + .build(); + return X509CertificateBuilder + .fromCsr(csr, + caCertificate.getSubjectX500Principal(), + Instant.EPOCH, + expiry.get().toInstant(), + caKeypair.getPrivate(), + SignatureAlgorithm.SHA256_WITH_ECDSA, + BigInteger.ONE) + .build(); + } + +} |