diff --git a/CHANGELOG.md b/CHANGELOG.md index 99b6f5c..aeabef6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * `read...Credentials()` methods for specific database mounts (#92) ### Features +* Support Vault transit API (#89) * Support PEM certificate string from `VAULT_CACERT` environment variable (#93) ### Dependencies diff --git a/pom.xml b/pom.xml index c1c5be4..b6e1ae7 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ de.stklcode.jvault jvault-connector - 1.4.1-SNAPSHOT + 1.5.0-SNAPSHOT jar diff --git a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java index 788af95..30b0bab 100644 --- a/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/HTTPVaultConnector.java @@ -68,6 +68,11 @@ public class HTTPVaultConnector implements VaultConnector { private static final String PATH_UNDELETE = "/undelete/"; private static final String PATH_DESTROY = "/destroy/"; + private static final String PATH_TRANSIT = "transit"; + private static final String PATH_TRANSIT_ENCRYPT = PATH_TRANSIT + "/encrypt/"; + private static final String PATH_TRANSIT_DECRYPT = PATH_TRANSIT + "/decrypt/"; + private static final String PATH_TRANSIT_HASH = PATH_TRANSIT + "/hash/"; + private final RequestHelper request; private boolean authorized = false; // Authorization status. @@ -646,6 +651,47 @@ public class HTTPVaultConnector implements VaultConnector { return true; } + @Override + public final TransitResponse transitEncrypt(final String keyName, final String plaintext) + throws VaultConnectorException { + requireAuth(); + + Map payload = mapOf( + "plaintext", plaintext + ); + + return request.post(PATH_TRANSIT_ENCRYPT + keyName, payload, token, TransitResponse.class); + } + + @Override + public final TransitResponse transitDecrypt(final String keyName, final String ciphertext) + throws VaultConnectorException { + requireAuth(); + + Map payload = mapOf( + "ciphertext", ciphertext + ); + + return request.post(PATH_TRANSIT_DECRYPT + keyName, payload, token, TransitResponse.class); + } + + @Override + public final TransitResponse transitHash(final String algorithm, final String input, final String format) + throws VaultConnectorException { + if (format != null && !"hex".equals(format) && !"base64".equals(format)) { + throw new IllegalArgumentException("Unsupported format " + format); + } + + requireAuth(); + + Map payload = mapOf( + "input", input, + "format", format + ); + + return request.post(PATH_TRANSIT_HASH + algorithm, payload, token, TransitResponse.class); + } + /** * Check for required authorization. * diff --git a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java index 8ad17ea..3a673a4 100644 --- a/src/main/java/de/stklcode/jvault/connector/VaultConnector.java +++ b/src/main/java/de/stklcode/jvault/connector/VaultConnector.java @@ -21,10 +21,7 @@ import de.stklcode.jvault.connector.model.*; import de.stklcode.jvault.connector.model.response.*; import java.io.Serializable; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.*; /** * Vault Connector interface. @@ -674,6 +671,82 @@ public interface VaultConnector extends AutoCloseable, Serializable { */ boolean deleteTokenRole(final String name) throws VaultConnectorException; + /** + * Encrypt plaintext via transit engine from Vault. + * + * @param keyName Transit key name + * @param plaintext Text to encrypt (Base64 encoded) + * @return Transit response + * @throws VaultConnectorException on error + * @since 1.5.0 + */ + TransitResponse transitEncrypt(final String keyName, final String plaintext) throws VaultConnectorException; + + /** + * Encrypt plaintext via transit engine from Vault. + * + * @param keyName Transit key name + * @param plaintext Binary data to encrypt + * @return Transit response + * @throws VaultConnectorException on error + * @since 1.5.0 + */ + default TransitResponse transitEncrypt(final String keyName, final byte[] plaintext) + throws VaultConnectorException { + return transitEncrypt(keyName, Base64.getEncoder().encodeToString(plaintext)); + } + + /** + * Decrypt ciphertext via transit engine from Vault. + * + * @param keyName Transit key name + * @param ciphertext Text to decrypt + * @return Transit response + * @throws VaultConnectorException on error + * @since 1.5.0 + */ + TransitResponse transitDecrypt(final String keyName, final String ciphertext) throws VaultConnectorException; + + /** + * Hash data in hex format via transit engine from Vault. + * + * @param algorithm Specifies the hash algorithm to use + * @param input Data to hash + * @return Transit response + * @throws VaultConnectorException on error + * @since 1.5.0 + */ + default TransitResponse transitHash(final String algorithm, final String input) throws VaultConnectorException { + return transitHash(algorithm, input, "hex"); + } + + /** + * Hash data via transit engine from Vault. + * + * @param algorithm Specifies the hash algorithm to use + * @param input Data to hash (Base64 encoded) + * @param format Specifies the output encoding (hex/base64) + * @return Transit response + * @throws VaultConnectorException on error + * @since 1.5.0 + */ + TransitResponse transitHash(final String algorithm, final String input, final String format) + throws VaultConnectorException; + + /** + * Hash data via transit engine from Vault. + * + * @param algorithm Specifies the hash algorithm to use + * @param input Data to hash + * @return Transit response + * @throws VaultConnectorException on error + * @since 1.5.0 + */ + default TransitResponse transitHash(final String algorithm, final byte[] input, final String format) + throws VaultConnectorException { + return transitHash(algorithm, Base64.getEncoder().encodeToString(input), format); + } + /** * Read credentials for MySQL backend at default mount point. * diff --git a/src/main/java/de/stklcode/jvault/connector/model/response/TransitResponse.java b/src/main/java/de/stklcode/jvault/connector/model/response/TransitResponse.java new file mode 100644 index 0000000..d7869f7 --- /dev/null +++ b/src/main/java/de/stklcode/jvault/connector/model/response/TransitResponse.java @@ -0,0 +1,92 @@ +/* + * Copyright 2016-2025 Stefan Kalscheuer + * + * 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 de.stklcode.jvault.connector.model.response; + +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonSetter; + +/** + * Response entity for transit operations. + * + * @author Stefan Kalscheuer + * @since 1.5.0 + */ +public class TransitResponse extends VaultDataResponse { + + private static final long serialVersionUID = 6873804240772242771L; + + private String ciphertext; + private String plaintext; + private String sum; + + @JsonSetter("data") + private void setData(Map data) { + ciphertext = data.get("ciphertext"); + plaintext = data.get("plaintext"); + sum = data.get("sum"); + } + + /** + * Get ciphertext. + * Populated after encryption. + * + * @return Ciphertext + */ + public String getCiphertext() { + return ciphertext; + } + + /** + * Get plaintext. + * Base64 encoded, populated after decryption. + * + * @return Plaintext + */ + public String getPlaintext() { + return plaintext; + } + + /** + * Get hash sum. + * Hex or Base64 string. Populated after hashing. + * + * @return Hash sum + */ + public String getSum() { + return sum; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } else if (o == null || getClass() != o.getClass() || !super.equals(o)) { + return false; + } + TransitResponse that = (TransitResponse) o; + return Objects.equals(ciphertext, that.ciphertext) && + Objects.equals(plaintext, that.plaintext) && + Objects.equals(sum, that.sum); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), ciphertext, plaintext, sum); + } +} diff --git a/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorIT.java b/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorIT.java index e1ca4d7..6a07c40 100644 --- a/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorIT.java +++ b/src/test/java/de/stklcode/jvault/connector/HTTPVaultConnectorIT.java @@ -989,6 +989,75 @@ class HTTPVaultConnectorIT { } } + @Nested + @DisplayName("Transit Tests") + class TransitTests { + + @Test + @DisplayName("Transit encryption") + void transitEncryptTest() { + assertDoesNotThrow(() -> connector.authToken(TOKEN_ROOT)); + assumeTrue(connector.isAuthorized()); + + TransitResponse transitResponse = assertDoesNotThrow( + () -> connector.transitEncrypt("my-key", "dGVzdCBtZQ=="), + "Failed to encrypt via transit" + ); + assertNotNull(transitResponse.getCiphertext()); + assertTrue(transitResponse.getCiphertext().startsWith("vault:v1:")); + + transitResponse = assertDoesNotThrow( + () -> connector.transitEncrypt("my-key", "test me".getBytes(UTF_8)), + "Failed to encrypt binary data via transit" + ); + assertNotNull(transitResponse.getCiphertext()); + assertTrue(transitResponse.getCiphertext().startsWith("vault:v1:")); + + } + + @Test + @DisplayName("Transit decryption") + void transitDecryptTest() { + assertDoesNotThrow(() -> connector.authToken(TOKEN_ROOT)); + assumeTrue(connector.isAuthorized()); + + TransitResponse transitResponse = assertDoesNotThrow( + () -> connector.transitDecrypt("my-key", "vault:v1:1mhLVkBAR2nrFtIkJF/qg57DWfRj0FWgR6tvkGO8XOnL6sw="), + "Failed to decrypt via transit" + ); + + assertEquals("dGVzdCBtZQ==", transitResponse.getPlaintext()); + } + + @Test + @DisplayName("Transit hash") + void transitHashText() { + assertDoesNotThrow(() -> connector.authToken(TOKEN_ROOT)); + assumeTrue(connector.isAuthorized()); + + TransitResponse transitResponse = assertDoesNotThrow( + () -> connector.transitHash("sha2-512", "dGVzdCBtZQ=="), + "Failed to hash via transit" + ); + + assertEquals("7677af0ee4effaa9f35e9b1e82d182f79516ab8321786baa23002de7c06851059492dd37d5fc3791f17d81d4b58198d24a6fd8bbd62c42c1c30b371da500f193", transitResponse.getSum()); + + TransitResponse transitResponseBase64 = assertDoesNotThrow( + () -> connector.transitHash("sha2-256", "dGVzdCBtZQ==", "base64"), + "Failed to hash via transit with base64 output" + ); + + assertEquals("5DfYkW7cvGLkfy36cXhqmZcygEy9HpnFNB4WWXKOl1M=", transitResponseBase64.getSum()); + + transitResponseBase64 = assertDoesNotThrow( + () -> connector.transitHash("sha2-256", "test me".getBytes(UTF_8), "base64"), + "Failed to hash binary data via transit" + ); + + assertEquals("5DfYkW7cvGLkfy36cXhqmZcygEy9HpnFNB4WWXKOl1M=", transitResponseBase64.getSum()); + } + } + @Nested @DisplayName("Misc Tests") class MiscTests { diff --git a/src/test/java/de/stklcode/jvault/connector/model/response/TransitResponseTest.java b/src/test/java/de/stklcode/jvault/connector/model/response/TransitResponseTest.java new file mode 100644 index 0000000..f47efe7 --- /dev/null +++ b/src/test/java/de/stklcode/jvault/connector/model/response/TransitResponseTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2016-2025 Stefan Kalscheuer + * + * 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 de.stklcode.jvault.connector.model.response; + +import com.fasterxml.jackson.core.JsonProcessingException; +import de.stklcode.jvault.connector.model.AbstractModelTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * JUnit Test for {@link TransitResponse} model. + * + * @author Stefan Kalscheuer + * @since 1.5.0 + */ +class TransitResponseTest extends AbstractModelTest { + private static final String CIPHERTEXT = "vault:v1:XjsPWPjqPrBi1N2Ms2s1QM798YyFWnO4TR4lsFA="; + private static final String PLAINTEXT = "dGhlIHF1aWNrIGJyb3duIGZveAo="; + private static final String SUM = "dGhlIHF1aWNrIGJyb3duIGZveAo="; + + TransitResponseTest() { + super(TransitResponse.class); + } + + @Override + protected TransitResponse createFull() { + try { + return objectMapper.readValue( + json( + "\"ciphertext\": \"" + CIPHERTEXT + "\", " + + "\"plaintext\": \"" + PLAINTEXT + "\", " + + "\"sum\": \"" + SUM + "\"" + ), + TransitResponse.class + ); + } catch (JsonProcessingException e) { + fail("Creation of full model failed", e); + return null; + } + } + + @Test + void encryptionTest() { + TransitResponse res = assertDoesNotThrow( + () -> objectMapper.readValue( + json("\"ciphertext\": \"" + CIPHERTEXT + "\""), + TransitResponse.class + ), + "TransitResponse deserialization failed" + ); + assertNotNull(res, "Parsed response is NULL"); + assertEquals("987c6daf-b0e2-4142-a970-1e61fdb249d7", res.getRequestId(), "Incorrect request id"); + assertEquals("", res.getLeaseId(), "Unexpected lease id"); + assertFalse(res.isRenewable(), "Unexpected renewable flag"); + assertEquals(0, res.getLeaseDuration(), "Unexpected lease duration"); + assertEquals(CIPHERTEXT, res.getCiphertext(), "Incorrect ciphertext"); + assertNull(res.getPlaintext(), "Unexpected plaintext"); + assertNull(res.getSum(), "Unexpected sum"); + assertNull(res.getWrapInfo(), "Unexpected wrap info"); + assertNull(res.getWarnings(), "Unexpected warnings"); + assertNull(res.getAuth(), "Unexpected auth"); + } + + @Test + void decryptionTest() { + TransitResponse res = assertDoesNotThrow( + () -> objectMapper.readValue( + json("\"plaintext\": \"" + PLAINTEXT + "\""), + TransitResponse.class + ), + "TransitResponse deserialization failed" + ); + assertNotNull(res, "Parsed response is NULL"); + assertEquals("987c6daf-b0e2-4142-a970-1e61fdb249d7", res.getRequestId(), "Incorrect request id"); + assertEquals("", res.getLeaseId(), "Unexpected lease id"); + assertFalse(res.isRenewable(), "Unexpected renewable flag"); + assertEquals(0, res.getLeaseDuration(), "Unexpected lease duration"); + assertNull(res.getCiphertext(), "Unexpected ciphertext"); + assertEquals(PLAINTEXT, res.getPlaintext(), "Incorrect plaintext"); + assertNull(res.getSum(), "Unexpected sum"); + assertNull(res.getWrapInfo(), "Unexpected wrap info"); + assertNull(res.getWarnings(), "Unexpected warnings"); + assertNull(res.getAuth(), "Unexpected auth"); + } + + @Test + void hashTest() { + TransitResponse res = assertDoesNotThrow( + () -> objectMapper.readValue( + json("\"sum\": \"" + SUM + "\""), + TransitResponse.class + ), + "TransitResponse deserialization failed" + ); + assertNotNull(res, "Parsed response is NULL"); + assertEquals("987c6daf-b0e2-4142-a970-1e61fdb249d7", res.getRequestId(), "Incorrect request id"); + assertEquals("", res.getLeaseId(), "Unexpected lease id"); + assertFalse(res.isRenewable(), "Unexpected renewable flag"); + assertEquals(0, res.getLeaseDuration(), "Unexpected lease duration"); + assertNull(res.getCiphertext(), "Unexpected ciphertext"); + assertNull(res.getPlaintext(), "Unexpected plaintext"); + assertEquals(SUM, res.getSum(), "Incorrect sum"); + assertNull(res.getWrapInfo(), "Unexpected wrap info"); + assertNull(res.getWarnings(), "Unexpected warnings"); + assertNull(res.getAuth(), "Unexpected auth"); + } + + private static String json(String data) { + return "{\n" + + " \"request_id\" : \"987c6daf-b0e2-4142-a970-1e61fdb249d7\",\n" + + " \"lease_id\" : \"\",\n" + + " \"renewable\" : false,\n" + + " \"lease_duration\" : 0,\n" + + " \"data\" : {\n" + + " " + data + "\n" + + " },\n" + + " \"wrap_info\" : null,\n" + + " \"warnings\" : null,\n" + + " \"auth\" : null\n" + + "}"; + } +} diff --git a/src/test/resources/data_dir/core/_mounts b/src/test/resources/data_dir/core/_mounts index a6d1bca..fe4d71c 100644 --- a/src/test/resources/data_dir/core/_mounts +++ b/src/test/resources/data_dir/core/_mounts @@ -1 +1 @@ -{"Value":"AAAAAQIOTEuNxtf2ZfGu6k9+65GFonDBiaetJnyLYFk1qfWPrE/VqUunbxuTv/QyK4pgC/q14sqypdxPe4Ygp/5mxzzuY6JXB0VKiDMXe9R5BltTG7++rLmKH/j+G7nEh71LER1/qVktU6x8dmDmTbpTgl5xzAB5DIXLMMKjjWda/7qJ3ivNI0pEOQ3JyT27SkqjqM49Dp1JIgKnjIEVQORqKcsSP+aqIncMjIo2iGXOGlDYAesp5tZ4hln3VCwXaIlrU8z6Mmntgcg7NeK/O2WTl86K644dbJUh6frGFDujrjOh/Pgp9n9u0BI3E9K9GD1Z1S1wEb1MCqzT/e9oHG7I7D8ku8PH11wGWGH6BmYlESYUG/KRVqu0XOQEfroLHZQiLE00yHBdStW/UJ9y5pmGpu1aiQ88Q8fpjF5xmRey99b4c6KUIpHjw5Af0XA9ZKsEJUyjS/dbMKPM6PBOW21LYefXH5b0poXMWgLW6LJV0zLuVMyVZJqANbzCVtheDPSsEjkHHwR/CLa2zs2Z6wJF3j1GDZxUFf4nbnuXQzz3M3kVPPtS6htlb0d8RN0/dkOrqUue+c4UjnXhiacXyVUnQQzUVu0sGak4vrQi0020aBzMXM2ZG7rNgvw+wcYFS4txO90kwJL6aOMXT2BeJiQ3wjjHb7M74/sSd0ffRTUlJSODSDotO7Q7Dfcnc1FLrIhXPRFPavTjFoL5HRy77xuGG7U7jTMoGra+rK54v0sxqKbS5WZi1hADQmAVIO8bPb+jA1qoejRFygW6sLgAdmEFQQJOhCV/BoKP3A=="} +{"Value":"AAAAAQJ7mykBIbHX5k81qdXEpvlLgRF1ZSlODETcB1JBZ7nj0Muskpvvl3jofN5XH1Td8ibJlrR0o5o/OUjZAz9t0Da+ZzCy4ga9G2SmgWkUAravTqfPO/ZxWh2hqTso2WPBXRM3/IeR0SAv/zh7m7JILxjKybJmnl9U6fkjPID/us0AscckZ9kgJ4g8jwaTzPfRp5U8jMebHYbABXZ65PeUOvNiDVcOvQDWPJrMxICz12xbeJ8mKs5MHpcNkLtPoRCQSpsh0YYTwkuF7NvpIciqIJ4/Yb7wYO+vp9AATbiIs3sSFBWxXEl0OAg/SAmOvaR27Y7/NHN//mg81jkMOHv62/Fxf11I8t1d63oyWFQhP0xR9eoq5hGNQ/30I2m1prhZtLRC2ieKASBDMxTzyNS5G5bsVXvhCsxn8tiC0Ma+ySOfxMQzBRfbx8rtoGmLFP+l/d6VMOPGFxmYqzLS5HvvpCryscCqLn7A8i6TMSrZiF7ZevyfEBpThqhJiYHzUxf07O5IAe6JBSGuNV9gLE+uJXaiYLedJwSfjRKwdQyzer730dU1IegW3KYTb/5hSW4eaETKkjc/alC536WlvAv+5FyckDBc3aVC+hHB7lKZG5YANkOUS3m5I8epemOmuKQ5pnXLOdDkQ14DyNCC79NwLltkp0d6sTNstQ44XAbs0HlLjs4EFwg5Hps9qHeXgTOXeAvwUerJgM20nKszlB+Oy+JzZm0xOK5xoJwy+z0/U3PtJ+7pwAipesIa2QEzqMt3EneuPuwEcv3bPUcowukq542sCEK0CuZjLqTUU9nNqiZan5f5pWuL0hYw4NFIkNfQyRlqgKpMaplDk/2fBqaV98yn0DWceEMWRY4NXpEMS+ysPDIeamX99auWqakb95AZ7tySpkRAkZYtq1nY5Nu0w7NyJrJZ1lhBHs2ZjW0tpXn2CL0MhMVArg=="} diff --git a/src/test/resources/data_dir/logical/c49ee9eb-94d9-928c-c958-95d092130c30/archive/_my-key b/src/test/resources/data_dir/logical/c49ee9eb-94d9-928c-c958-95d092130c30/archive/_my-key new file mode 100644 index 0000000..d58d0fa --- /dev/null +++ b/src/test/resources/data_dir/logical/c49ee9eb-94d9-928c-c958-95d092130c30/archive/_my-key @@ -0,0 +1 @@ +{"Value":"AAAAAQIwsBgPcs7Hxl7UnB8OkTq9+5HERx4Fzn8jAzR8uIhoQZfpNG/15m2LEEsDJw1o+lxc6u+h7XcOB1QRqrKz8k7CFR15A+qsxD0UclHZdnk+MmMwXL9SSvYugp7XPiXmpG/uTZVf1QtigXQuehEEJeFfJVI7aFACu2AHEGVt+5b6yDQEgTUruo0seazRXuVU+J6NFCDU3ZvkR8e0al6OcMail59KamzjSCYiqDF4TeUuOQ6Zyr7zIG7gSaas1sog2JIlvrh+wkHW6I+OyD9SJSTinGEGRXl0tq15qoBJ4srfXdWODmKtExEupArbiDR5PyvSI3KlIHKIFduslJZKkJwiV3dBscdva4Rqb98FffMVYuM4G4s+VpvDDX+WVsqWF1ssoHRAFWCNAJGsenVDTxblzAF/4rGkJ7LC9yjLGsBtMOCkCZAKDQ3C9VFu+LGhbMRA5p2RKxNKWGem4Cyp5AqMmx62UzDAgMLGgm89A0g9s1/3FnCoLPdVmlWc6cg4QahN30qJCInJeAmH7T9NwIjv6/QxyJxyDVtdMtcfnMNxx15Q29lbyWTcbaI1iabHpc16iS/gwAPQeBTSbcvc4OxqwC5PDFThFq6bwZXaekYz4ghC13j9Ht79GVKH9cPZb5M="} diff --git a/src/test/resources/data_dir/logical/c49ee9eb-94d9-928c-c958-95d092130c30/policy/_my-key b/src/test/resources/data_dir/logical/c49ee9eb-94d9-928c-c958-95d092130c30/policy/_my-key new file mode 100644 index 0000000..5a07db5 --- /dev/null +++ b/src/test/resources/data_dir/logical/c49ee9eb-94d9-928c-c958-95d092130c30/policy/_my-key @@ -0,0 +1 @@ +{"Value":"AAAAAQKfotDJ0SihB+f5i4PxZ6lq1kxtH4QMprGI7Hj8HimEwXsW0Gbj/1B7YM4DQt4t5JFD4gKwFVOwlJDyusaJj92ar0QLSreCWyJTKUadqZplMFyB/bAdK42QdH+ZS8Z9KHUNchbRpNhnvOFIahoDG8dZ+nbCIXblJONCaey4/ri3H+GQbk/jfre1VByh7zVIN0ISew5PzZRCTkbO1CvcQZhrRGoUGiPmLywKVbHEMsvimVuZY5py6OfL+70QmElBmN9O45bTgX9XPbfSmyQIcGrElO1foi0WwZLPsb/fZcAIgrhC768jOnzeimChoX4zc0DPxuyV1YPvsD1yAlsnsFuJW6CP7TGkszbJU+rwDpg0TgqKtvFr1Lgkxyfcxg0h++1BSiEgoJU7b2IgIWP7reJVjc1tbAsoR1tOBCSAAhvqWZVpn2oht5rfe9aN370bV3Jcu17hFWyhB+VhzbCCPRcofPXX3f2U2dcQ0X7bU4nMiq1v6NQP6u/D5GAPj4Jc0519tPW4KQrd9SNqR20ct6OvqxjMFWV4GZXVcsL4+3xup87Yib6EDb0+hhe6XEpC6isYgD3D5OTTOWphHlsglGkGFi9lUc+h8zNPM4FHwha6uVTLmaqaLGbLziwT9WXF1ATacwtNW0t3kZlFUBvMwSwWzoPqO1+jxs+id4ie/VI6ZTOeowi4ceK2eWJ1/t9MB/gjvadpgE+FYt5QG6dFav4ujQN5Ne/yY78PGF5tp0CT4koox0rMUuxyD1xOIXkm0NBpJm1y9/J06yqLpMKqS40/jGcSQaycRngXDb+6H9rj7mheiO4qxcFGqViqECCUjDG3PnLP/fy9py5kFq7mf0pq7L0Jq/lLWC+iKJF9UaZmCaz8DwlQ9zC03XOFqABPNe8gMFlb8zU09VKBbY+g5gukOonjcBeoFOTRqQxuaWwwwB2lj8XnZScOyIcVJGkH"}