From 5aaefececaa2c241379d3a1a432b2575c782cf28 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Tue, 31 Jul 2018 18:05:02 +0200 Subject: [PATCH] Introduce VaultWrappingTemplate to abstract wrapping operations. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We now provide a Template API to interact with Vault wrapping endpoints introduced in Vault 0.6.2. Object body = …; WrappedMetadata metadata = wrappingOperations.wrap(body, Duration.ofSeconds(100)); VaultResponse response = wrappingOperations.read(metadata.getToken()); Closes gh-208. --- .../vault/core/VaultOperations.java | 7 + .../vault/core/VaultTemplate.java | 13 +- .../vault/core/VaultWrappingOperations.java | 86 +++++++ .../vault/core/VaultWrappingTemplate.java | 219 ++++++++++++++++++ .../vault/support/WrappedMetadata.java | 75 ++++++ ...VaultWrappingTemplateIntegrationTests.java | 169 ++++++++++++++ 6 files changed, 565 insertions(+), 4 deletions(-) create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/VaultWrappingOperations.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/core/VaultWrappingTemplate.java create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/support/WrappedMetadata.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/core/VaultWrappingTemplateIntegrationTests.java diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java index 1cfdb8c1..b28df217 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultOperations.java @@ -102,6 +102,13 @@ public interface VaultOperations { */ VaultTransitOperations opsForTransit(String path); + /** + * @return the operations interface to interact with the Vault system/wrapping + * endpoints. + * @since 2.1 + */ + VaultWrappingOperations opsForWrapping(); + /** * Read from a Vault path. Reading data using this method is suitable for API * calls/secret backends that do not require a request body. diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java index 3f2ed254..6b5f0aed 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultTemplate.java @@ -256,6 +256,11 @@ public class VaultTemplate implements InitializingBean, VaultOperations, Disposa return new VaultTransitTemplate(this, path); } + @Override + public VaultWrappingOperations opsForWrapping() { + return new VaultWrappingTemplate(this); + } + @Override public VaultResponse read(String path) { @@ -267,9 +272,9 @@ public class VaultTemplate implements InitializingBean, VaultOperations, Disposa @SuppressWarnings("unchecked") @Override @Nullable - public VaultResponseSupport read(final String path, final Class responseType) { + public VaultResponseSupport read(String path, Class responseType) { - final ParameterizedTypeReference> ref = VaultResponses + ParameterizedTypeReference> ref = VaultResponses .getTypeReference(responseType); try { @@ -320,7 +325,7 @@ public class VaultTemplate implements InitializingBean, VaultOperations, Disposa } @Override - public void delete(final String path) { + public void delete(String path) { Assert.hasText(path, "Path must not be empty"); @@ -364,7 +369,7 @@ public class VaultTemplate implements InitializingBean, VaultOperations, Disposa } @Nullable - private T doRead(final String path, final Class responseType) { + private T doRead(String path, Class responseType) { return doWithSession(restOperations -> { diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultWrappingOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultWrappingOperations.java new file mode 100644 index 00000000..fe78f3f3 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultWrappingOperations.java @@ -0,0 +1,86 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.vault.core; + +import java.time.Duration; + +import org.springframework.lang.Nullable; +import org.springframework.vault.VaultException; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultResponseSupport; +import org.springframework.vault.support.VaultToken; +import org.springframework.vault.support.WrappedMetadata; + +/** + * Interface that specifies wrapping-related operations. + * + * @author Mark Paluch + * @since 2.1 + */ +public interface VaultWrappingOperations { + + /** + * Looks up {@link WrappedMetadata metadata} for the given token containing a wrapped + * response. + * + * @param token must not be {@literal null}. + * @return the {@link WrappedMetadata} the {@code token} or {@literal null} if the + * token was invalid or expired. + */ + @Nullable + WrappedMetadata lookup(VaultToken token); + + /** + * Read a wrapped secret. + * + * @param token must not be {@literal null}. + * @return the data or {@literal null} if the token was invalid or expired. + */ + @Nullable + VaultResponse read(VaultToken token); + + /** + * Read a wrapped secret of type {@link Class responseType}. + * + * @param token must not be {@literal null}. + * @param responseType must not be {@literal null}. + * @return the data or {@literal null} if the token was invalid or expired. + */ + @Nullable + VaultResponseSupport read(VaultToken token, Class responseType); + + /** + * Rewraps a response-wrapped token. The new token will use the same creation TTL as + * the original token and contain the same response. The old token will be + * invalidated. This can be used for long-term storage of a secret in a + * response-wrapped token when rotation is a requirement. Rewrapping with an invalid + * token throws {@link VaultException}. + * + * @param token must not be {@literal null}. + * @return the {@link WrappedMetadata} for this wrapping operation. + */ + WrappedMetadata rewrap(VaultToken token); + + /** + * Wraps the given user-supplied data inside a response-wrapped token. + * + * @param body must not be {@literal null}. + * @param ttl must not be {@literal null}. + * @return the {@link WrappedMetadata} for this wrapping operation. + */ + WrappedMetadata wrap(Object body, Duration ttl); + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultWrappingTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultWrappingTemplate.java new file mode 100644 index 00000000..057e6ccc --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultWrappingTemplate.java @@ -0,0 +1,219 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.vault.core; + +import java.time.Duration; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.Collections; +import java.util.Map; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.vault.VaultException; +import org.springframework.vault.client.VaultHttpHeaders; +import org.springframework.vault.client.VaultResponses; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultResponseSupport; +import org.springframework.vault.support.VaultToken; +import org.springframework.vault.support.WrappedMetadata; +import org.springframework.web.client.HttpStatusCodeException; + +/** + * @author Mark Paluch + */ +public class VaultWrappingTemplate implements VaultWrappingOperations { + + private final VaultOperations vaultOperations; + + /** + * Create a new {@link VaultWrappingTemplate} given {@link VaultOperations}. + * + * @param vaultOperations must not be {@literal null}. + */ + public VaultWrappingTemplate(VaultOperations vaultOperations) { + + Assert.notNull(vaultOperations, "VaultOperations must not be null"); + + this.vaultOperations = vaultOperations; + } + + @Nullable + @Override + public WrappedMetadata lookup(VaultToken token) { + + Assert.notNull(token, "token VaultToken not be null"); + + VaultResponse response = null; + try { + response = vaultOperations.write("sys/wrapping/lookup", + Collections.singletonMap("token", token.getToken())); + } + catch (VaultException e) { + + if (e.getMessage() != null && e.getMessage().contains("does not exist")) { + return null; + } + + throw e; + } + + if (response == null) { + return null; + } + + return getWrappedMetadata(response.getData(), token); + } + + @Nullable + @Override + public VaultResponse read(VaultToken token) { + + return vaultOperations.doWithVault(restOperations -> { + + HttpHeaders headers = VaultHttpHeaders.from(token); + try { + return restOperations.exchange("sys/wrapping/unwrap", HttpMethod.POST, + new HttpEntity<>(headers), VaultResponse.class).getBody(); + } + catch (HttpStatusCodeException e) { + + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + return null; + } + + if (e.getStatusCode() == HttpStatus.BAD_REQUEST + && e.getResponseBodyAsString().contains("does not exist")) { + return null; + } + + throw VaultResponses.buildException(e, "sys/wrapping/unwrap"); + } + }); + } + + @Nullable + @Override + public VaultResponseSupport read(VaultToken token, Class responseType) { + + ParameterizedTypeReference> ref = VaultResponses + .getTypeReference(responseType); + + return vaultOperations.doWithVault(restOperations -> { + + HttpHeaders headers = VaultHttpHeaders.from(token); + try { + return restOperations.exchange("sys/wrapping/unwrap", HttpMethod.POST, + new HttpEntity<>(headers), ref).getBody(); + } + catch (HttpStatusCodeException e) { + + if (e.getStatusCode() == HttpStatus.NOT_FOUND) { + return null; + } + + if (e.getStatusCode() == HttpStatus.BAD_REQUEST + && e.getResponseBodyAsString().contains("does not exist")) { + return null; + } + + throw VaultResponses.buildException(e, "sys/wrapping/unwrap"); + } + }); + } + + @Override + public WrappedMetadata rewrap(VaultToken token) { + + Assert.notNull(token, "token VaultToken not be null"); + + VaultResponse response = vaultOperations.write("sys/wrapping/rewrap", + Collections.singletonMap("token", token.getToken())); + + Map wrapInfo = response.getWrapInfo(); + + return getWrappedMetadata(wrapInfo, VaultToken.of(wrapInfo.get("token"))); + } + + @Override + public WrappedMetadata wrap(Object body, Duration duration) { + + Assert.notNull(body, "Body must not be null"); + Assert.notNull(duration, "TTL duration must not be null"); + + VaultResponse response = vaultOperations.doWithSession(restOperations -> { + + HttpHeaders headers = new HttpHeaders(); + headers.add("X-Vault-Wrap-TTL", Long.toString(duration.getSeconds())); + + return restOperations.exchange("sys/wrapping/wrap", HttpMethod.POST, + new HttpEntity<>(body, headers), VaultResponse.class).getBody(); + }); + + Map wrapInfo = response.getWrapInfo(); + + return getWrappedMetadata(wrapInfo, VaultToken.of(wrapInfo.get("token"))); + } + + private static WrappedMetadata getWrappedMetadata(Map wrapInfo, + VaultToken token) { + + TemporalAccessor creation_time = getDate(wrapInfo, "creation_time"); + String path = (String) wrapInfo.get("creation_path"); + Duration ttl = getTtl(wrapInfo); + + return new WrappedMetadata(token, ttl, Instant.from(creation_time), path); + } + + @Nullable + private static TemporalAccessor getDate(Map responseMetadata, String key) { + + String date = (String) ((Map) responseMetadata).getOrDefault(key, ""); + + return StringUtils.hasText(date) ? DateTimeFormatter.ISO_OFFSET_DATE_TIME + .parse(date) : null; + } + + @Nullable + private static Duration getTtl(Map wrapInfo) { + + Object creationTtl = wrapInfo.get("ttl"); + + if (creationTtl == null) { + creationTtl = wrapInfo.get("creation_ttl"); + } + + if (creationTtl instanceof String) { + creationTtl = Integer.parseInt((String) creationTtl); + } + + Duration ttl = null; + + if (creationTtl instanceof Integer) { + ttl = Duration.ofSeconds((Integer) creationTtl); + + } + return ttl; + } + +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/WrappedMetadata.java b/spring-vault-core/src/main/java/org/springframework/vault/support/WrappedMetadata.java new file mode 100644 index 00000000..fa0e9751 --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/WrappedMetadata.java @@ -0,0 +1,75 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.vault.support; + +import java.time.Duration; +import java.time.Instant; + +import org.springframework.util.Assert; + +/** + * Value object representing wrapped secret metadata. + * + * @author Mark Paluch + * @since 2.1 + */ +public class WrappedMetadata { + + private final VaultToken token; + + private final Instant creationTime; + + private final String path; + + private final Duration ttl; + + /** + * Creates a new {@link WrappedMetadata}. + * @param token must not be {@literal null}. + * @param ttl must not be {@literal null}. + * @param creationTime must not be {@literal null}. + * @param path must not be {@literal null}. + */ + public WrappedMetadata(VaultToken token, Duration ttl, Instant creationTime, + String path) { + + Assert.notNull(token, "VaultToken must not be null"); + Assert.notNull(ttl, "TTL duration must not be null"); + Assert.notNull(creationTime, "Creation time must not be null"); + Assert.notNull(path, "Path must not be null"); + + this.token = token; + this.creationTime = creationTime; + this.path = path; + this.ttl = ttl; + } + + public VaultToken getToken() { + return token; + } + + public Instant getCreationTime() { + return creationTime; + } + + public String getPath() { + return path; + } + + public Duration getTtl() { + return ttl; + } +} diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultWrappingTemplateIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultWrappingTemplateIntegrationTests.java new file mode 100644 index 00000000..4b5ef3ae --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultWrappingTemplateIntegrationTests.java @@ -0,0 +1,169 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.vault.core; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collections; +import java.util.Map; + +import lombok.EqualsAndHashCode; +import lombok.Value; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.vault.VaultException; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultResponseSupport; +import org.springframework.vault.support.VaultToken; +import org.springframework.vault.support.WrappedMetadata; +import org.springframework.vault.util.IntegrationTestSupport; +import org.springframework.vault.util.Version; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assume.assumeTrue; + +/** + * Integration tests for {@link VaultWrappingTemplate} through + * {@link VaultWrappingOperations}. + * + * @author Mark Paluch + */ +@RunWith(SpringRunner.class) +@ContextConfiguration(classes = VaultIntegrationTestConfiguration.class) +public class VaultWrappingTemplateIntegrationTests extends IntegrationTestSupport { + + private static final Version WRAPPING_ENDPOINT_INTRODUCED_IN_VERSION = Version + .parse("0.6.2"); + + @Autowired + private VaultOperations vaultOperations; + + private VaultWrappingOperations wrappingOperations; + + private Version vaultVersion; + + @Before + public void before() { + + wrappingOperations = vaultOperations.opsForWrapping(); + + vaultVersion = prepare().getVersion(); + + assumeTrue(vaultVersion + .isGreaterThanOrEqualTo(WRAPPING_ENDPOINT_INTRODUCED_IN_VERSION)); + } + + @Test + public void shouldCreateWrappedSecret() { + + Map map = Collections.singletonMap("key", "value"); + + WrappedMetadata metadata = wrappingOperations.wrap(map, Duration.ofSeconds(100)); + + assertThat(metadata.getPath()).isEqualTo("sys/wrapping/wrap"); + assertThat(metadata.getTtl()).isEqualTo(Duration.ofSeconds(100)); + assertThat(metadata.getToken()).isNotNull(); + assertThat(metadata.getCreationTime()).isBefore(Instant.now().plusSeconds(60)) + .isAfter(Instant.now().minusSeconds(60)); + } + + @Test + public void shouldLookupWrappedSecret() { + + Map map = Collections.singletonMap("key", "value"); + + WrappedMetadata metadata = wrappingOperations.wrap(map, Duration.ofSeconds(100)); + + WrappedMetadata lookup = wrappingOperations.lookup(metadata.getToken()); + + assertThat(lookup.getPath()).isEqualTo("sys/wrapping/wrap"); + assertThat(lookup.getTtl()).isEqualTo(Duration.ofSeconds(100)); + assertThat(lookup.getToken()).isNotNull(); + assertThat(lookup.getCreationTime()).isBefore(Instant.now().plusSeconds(60)) + .isAfter(Instant.now().minusSeconds(60)); + } + + @Test + public void shouldReadWrappedSecret() { + + Map map = Collections.singletonMap("key", "value"); + + WrappedMetadata metadata = wrappingOperations.wrap(map, Duration.ofSeconds(100)); + VaultResponse response = wrappingOperations.read(metadata.getToken()); + + assertThat(response.getData()) + .isEqualTo(Collections.singletonMap("key", "value")); + } + + @Test + public void shouldReadWrappedTypedSecret() { + + Map map = Collections.singletonMap("key", "value"); + + WrappedMetadata metadata = wrappingOperations.wrap(map, Duration.ofSeconds(100)); + VaultResponseSupport response = wrappingOperations.read( + metadata.getToken(), Secret.class); + + assertThat(response.getData()).isEqualTo(new Secret("value")); + } + + @Test + public void shouldReturnNullForNonExistentSecret() { + + assertThat(wrappingOperations.read(VaultToken.of("foo"))).isNull(); + assertThat(wrappingOperations.read(VaultToken.of("foo"), Map.class)).isNull(); + } + + @Test + public void shouldLookupAbsentSecret() { + + WrappedMetadata lookup = wrappingOperations.lookup(VaultToken.of("foo")); + + assertThat(lookup).isNull(); + } + + @Test + public void shouldRewrapSecret() { + + Map map = Collections.singletonMap("key", "value"); + + WrappedMetadata metadata = wrappingOperations.wrap(map, Duration.ofSeconds(100)); + + WrappedMetadata rewrap = wrappingOperations.rewrap(metadata.getToken()); + + assertThat(rewrap.getPath()).isEqualTo("sys/wrapping/wrap"); + assertThat(rewrap.getTtl()).isEqualTo(Duration.ofSeconds(100)); + assertThat(rewrap.getToken()).isNotEqualTo(metadata.getToken()); + assertThat(rewrap.getCreationTime()).isBefore(Instant.now().plusSeconds(60)) + .isAfter(Instant.now().minusSeconds(60)); + } + + @Test(expected = VaultException.class) + public void shouldRewrapAbsentSecret() { + wrappingOperations.rewrap(VaultToken.of("foo")); + } + + @Value + @EqualsAndHashCode + static class Secret { + final String key; + } +}