diff --git a/common4j/src/main/com/microsoft/identity/common/java/crypto/KeyMetadata.java b/common4j/src/main/com/microsoft/identity/common/java/crypto/KeyMetadata.java new file mode 100644 index 0000000000..1c19454d06 --- /dev/null +++ b/common4j/src/main/com/microsoft/identity/common/java/crypto/KeyMetadata.java @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.crypto; + +import org.json.JSONException; +import org.json.JSONObject; + +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; + +/** + * Immutable metadata describing a symmetric encryption key version. + * + *
Used by the KeyVersionRegistry to track the lifecycle of symmetric keys and by + * encryption managers to select the active key or fall back to deprecated keys for + * decryption only.
+ */ +@Getter +@Builder +public final class KeyMetadata { + + /** Default symmetric encryption algorithm. */ + public static final String DEFAULT_ALGORITHM = "AES/CBC/PKCS5Padding"; + + /** Default key size in bits. */ + public static final int DEFAULT_KEY_SIZE = 256; + + private static final String FIELD_VERSION_ID = "versionId"; + private static final String FIELD_CREATED_AT_MILLIS = "createdAtMillis"; + private static final String FIELD_ALGORITHM = "algorithm"; + private static final String FIELD_KEY_SIZE = "keySize"; + private static final String FIELD_IS_DEPRECATED = "isDeprecated"; + + /** + * Key identifier, e.g. {@code "K001"}, {@code "K002"}. + */ + private final String versionId; + + /** + * Unix timestamp (milliseconds) at which this key was created. + */ + private final long createdAtMillis; + + /** + * Encryption algorithm for this key (e.g. {@code "AES/CBC/PKCS5Padding"}). + * Defaults to {@link #DEFAULT_ALGORITHM}. + */ + @Builder.Default + private final String algorithm = DEFAULT_ALGORITHM; + + /** + * Key size in bits (e.g. {@code 256}). + * Defaults to {@link #DEFAULT_KEY_SIZE}. + */ + @Builder.Default + private final int keySize = DEFAULT_KEY_SIZE; + + /** + * When {@code true} the key may only be used for decryption; new encryptions must use + * a non-deprecated key. + */ + @Builder.Default + private final boolean deprecated = false; + + /** + * All-args constructor called by the Lombok-generated builder; validates required fields. + * + * @throws IllegalStateException if {@code versionId} is null or blank. + * @throws IllegalArgumentException if {@code keySize} is not positive. + */ + private KeyMetadata(final String versionId, final long createdAtMillis, + final String algorithm, final int keySize, final boolean deprecated) { + if (versionId == null || versionId.trim().isEmpty()) { + throw new IllegalStateException("versionId must be a non-blank string."); + } + if (keySize <= 0) { + throw new IllegalArgumentException("keySize must be a positive value."); + } + this.versionId = versionId; + this.createdAtMillis = createdAtMillis; + this.algorithm = algorithm; + this.keySize = keySize; + this.deprecated = deprecated; + } + + /** + * Serializes this instance to a JSON string. + * + * @return a JSON string representation of this {@link KeyMetadata}. + * @throws JSONException if serialization fails. + */ + @NonNull + public String toJson() throws JSONException { + final JSONObject json = new JSONObject(); + json.put(FIELD_VERSION_ID, versionId); + json.put(FIELD_CREATED_AT_MILLIS, createdAtMillis); + json.put(FIELD_ALGORITHM, algorithm); + json.put(FIELD_KEY_SIZE, keySize); + json.put(FIELD_IS_DEPRECATED, deprecated); + return json.toString(); + } + + /** + * Deserializes a {@link KeyMetadata} instance from a JSON string. + * + *{@code algorithm}, {@code keySize}, and {@code deprecated} are optional in the JSON; + * missing fields fall back to their defaults ({@link #DEFAULT_ALGORITHM}, + * {@link #DEFAULT_KEY_SIZE}, and {@code false} respectively). Only {@code versionId} and + * {@code createdAtMillis} are required.
+ * + * @param json the JSON string produced by {@link #toJson()}. + * @return a reconstructed {@link KeyMetadata} instance. + * @throws JSONException if {@code json} is malformed or missing required fields. + */ + @NonNull + public static KeyMetadata fromJson(@NonNull final String json) throws JSONException { + final JSONObject obj = new JSONObject(json); + return KeyMetadata.builder() + .versionId(obj.getString(FIELD_VERSION_ID)) + .createdAtMillis(obj.getLong(FIELD_CREATED_AT_MILLIS)) + .algorithm(obj.optString(FIELD_ALGORITHM, DEFAULT_ALGORITHM)) + .keySize(obj.optInt(FIELD_KEY_SIZE, DEFAULT_KEY_SIZE)) + .deprecated(obj.optBoolean(FIELD_IS_DEPRECATED, false)) + .build(); + } +} diff --git a/common4j/src/test/com/microsoft/identity/common/java/crypto/KeyMetadataTest.java b/common4j/src/test/com/microsoft/identity/common/java/crypto/KeyMetadataTest.java new file mode 100644 index 0000000000..336149e79c --- /dev/null +++ b/common4j/src/test/com/microsoft/identity/common/java/crypto/KeyMetadataTest.java @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +package com.microsoft.identity.common.java.crypto; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Assert; +import org.junit.Test; + +public class KeyMetadataTest { + + private static final String VERSION_ID = "K001"; + private static final long CREATED_AT_MILLIS = 1_700_000_000_000L; + private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; + private static final int KEY_SIZE = 256; + + @Test + public void testBuilder_setsAllFields() { + final KeyMetadata metadata = KeyMetadata.builder() + .versionId(VERSION_ID) + .createdAtMillis(CREATED_AT_MILLIS) + .algorithm(ALGORITHM) + .keySize(KEY_SIZE) + .deprecated(false) + .build(); + + Assert.assertEquals(VERSION_ID, metadata.getVersionId()); + Assert.assertEquals(CREATED_AT_MILLIS, metadata.getCreatedAtMillis()); + Assert.assertEquals(ALGORITHM, metadata.getAlgorithm()); + Assert.assertEquals(KEY_SIZE, metadata.getKeySize()); + Assert.assertFalse(metadata.isDeprecated()); + } + + @Test + public void testBuilder_defaultValues() { + final KeyMetadata metadata = KeyMetadata.builder() + .versionId(VERSION_ID) + .createdAtMillis(CREATED_AT_MILLIS) + .build(); + + Assert.assertEquals(KeyMetadata.DEFAULT_ALGORITHM, metadata.getAlgorithm()); + Assert.assertEquals(KeyMetadata.DEFAULT_KEY_SIZE, metadata.getKeySize()); + Assert.assertFalse(metadata.isDeprecated()); + } + + @Test(expected = IllegalStateException.class) + public void testBuilder_throwsWhenVersionIdMissing() { + KeyMetadata.builder() + .createdAtMillis(CREATED_AT_MILLIS) + .build(); + } + + @Test(expected = IllegalStateException.class) + public void testBuilder_throwsWhenVersionIdEmpty() { + KeyMetadata.builder() + .versionId("") + .createdAtMillis(CREATED_AT_MILLIS) + .build(); + } + + @Test + public void testToJson_producesValidJson() throws JSONException { + final KeyMetadata metadata = KeyMetadata.builder() + .versionId(VERSION_ID) + .createdAtMillis(CREATED_AT_MILLIS) + .algorithm(ALGORITHM) + .keySize(KEY_SIZE) + .deprecated(true) + .build(); + + final JSONObject json = new JSONObject(metadata.toJson()); + Assert.assertEquals(VERSION_ID, json.getString("versionId")); + Assert.assertEquals(CREATED_AT_MILLIS, json.getLong("createdAtMillis")); + Assert.assertEquals(ALGORITHM, json.getString("algorithm")); + Assert.assertEquals(KEY_SIZE, json.getInt("keySize")); + Assert.assertTrue(json.getBoolean("isDeprecated")); + } + + @Test + public void testFromJson_reconstructsObject() throws JSONException { + final KeyMetadata original = KeyMetadata.builder() + .versionId(VERSION_ID) + .createdAtMillis(CREATED_AT_MILLIS) + .algorithm(ALGORITHM) + .keySize(KEY_SIZE) + .deprecated(false) + .build(); + + final KeyMetadata reconstructed = KeyMetadata.fromJson(original.toJson()); + + Assert.assertEquals(original.getVersionId(), reconstructed.getVersionId()); + Assert.assertEquals(original.getCreatedAtMillis(), reconstructed.getCreatedAtMillis()); + Assert.assertEquals(original.getAlgorithm(), reconstructed.getAlgorithm()); + Assert.assertEquals(original.getKeySize(), reconstructed.getKeySize()); + Assert.assertEquals(original.isDeprecated(), reconstructed.isDeprecated()); + } + + @Test + public void testFromJson_reconstructsDeprecatedKey() throws JSONException { + final KeyMetadata original = KeyMetadata.builder() + .versionId("K002") + .createdAtMillis(CREATED_AT_MILLIS) + .deprecated(true) + .build(); + + final KeyMetadata reconstructed = KeyMetadata.fromJson(original.toJson()); + + Assert.assertEquals("K002", reconstructed.getVersionId()); + Assert.assertTrue(reconstructed.isDeprecated()); + } + + @Test + public void testFromJson_usesDefaultsForOptionalFields() throws JSONException { + // JSON with only required fields + final String minimalJson = "{\"versionId\":\"K003\",\"createdAtMillis\":1700000000000}"; + final KeyMetadata metadata = KeyMetadata.fromJson(minimalJson); + + Assert.assertEquals("K003", metadata.getVersionId()); + Assert.assertEquals(KeyMetadata.DEFAULT_ALGORITHM, metadata.getAlgorithm()); + Assert.assertEquals(KeyMetadata.DEFAULT_KEY_SIZE, metadata.getKeySize()); + Assert.assertFalse(metadata.isDeprecated()); + } + + @Test(expected = JSONException.class) + public void testFromJson_throwsOnMalformedJson() throws JSONException { + KeyMetadata.fromJson("not valid json"); + } + + @Test(expected = IllegalArgumentException.class) + public void testBuilder_throwsOnInvalidKeySize() { + KeyMetadata.builder() + .versionId(VERSION_ID) + .createdAtMillis(CREATED_AT_MILLIS) + .keySize(0) + .build(); + } +}