response = new LinkedHashMap<>();
+ response.put("value", java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes));
+ return Response.ok(toJson(response), "application/json").build();
+ }
+
+ private static boolean isCryptoOp(String op) {
+ return op.equals("encrypt") || op.equals("decrypt") || op.equals("wrapkey")
+ || op.equals("unwrapkey") || op.equals("sign") || op.equals("verify");
+ }
+
+ private static String pathLabel(String name, String remainder) {
+ return "keys/" + name + "/" + remainder;
+ }
+
// -------------------------------------------------------------------------
// Secret CRUD
// -------------------------------------------------------------------------
diff --git a/src/main/java/io/floci/az/services/keyvault/KeyVaultKeys.java b/src/main/java/io/floci/az/services/keyvault/KeyVaultKeys.java
new file mode 100644
index 00000000..74845996
--- /dev/null
+++ b/src/main/java/io/floci/az/services/keyvault/KeyVaultKeys.java
@@ -0,0 +1,1040 @@
+package io.floci.az.services.keyvault;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.floci.az.core.AzureRequest;
+import io.floci.az.core.StoredObject;
+import io.floci.az.core.arm.ArmJson;
+import io.floci.az.core.storage.StorageBackend;
+import jakarta.ws.rs.core.Response;
+import org.jboss.logging.Logger;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+/**
+ * Key-object business logic for the Key Vault data plane: CRUD, versions, soft-delete / recover /
+ * purge, backup / restore, rotation policy, and crypto dispatch.
+ *
+ * Plain object (not a CDI bean) built by {@link KeyVaultHandler} with the shared
+ * {@code "keyvault"} {@link StorageBackend}; keys are stored under {@code {account}/keys/...} to
+ * avoid colliding with secrets. Crypto delegates to the pure-JDK {@link KeyVaultCrypto} engine.
+ */
+final class KeyVaultKeys {
+
+ private static final Logger LOG = Logger.getLogger(KeyVaultKeys.class);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final Base64.Encoder URL = Base64.getUrlEncoder().withoutPadding();
+ private static final String RECOVERY_LEVEL = "Purgeable";
+ private static final long PURGE_RETENTION_SECONDS = 7L * 24 * 3600;
+
+ private final StorageBackend store;
+
+ KeyVaultKeys(StorageBackend store) {
+ this.store = store;
+ }
+
+ // ── Create / import ────────────────────────────────────────────────────────
+
+ Response createKey(AzureRequest req, String account, String name, boolean hsm) {
+ Response invalid = validateKeyName(name);
+ if (invalid != null) {
+ return invalid;
+ }
+ Map body = parseBody(req);
+ String kty = bodyString(body, "kty", "RSA");
+ int keySize = bodyInt(body, "key_size", 0);
+ String curve = body.containsKey("curve") ? bodyString(body, "curve", null)
+ : bodyString(body, "crv", null);
+ @SuppressWarnings("unchecked")
+ List keyOps = body.containsKey("key_ops")
+ ? (body.get("key_ops") instanceof List> l ? (List) l : List.of())
+ : KeyVaultCrypto.defaultKeyOps(KeyVaultCrypto.baseKty(kty));
+
+ Map jwk;
+ try {
+ jwk = KeyVaultCrypto.generateJwk(kty, keySize, curve, keyOps);
+ } catch (KeyVaultCrypto.CryptoException e) {
+ return kvError(400, "BadParameter", e.getMessage());
+ }
+ jwk.put("hsm", kty.endsWith("-HSM"));
+ jwk.put("tags", stringMap(body.get("tags")));
+
+ return storeNewKey(req, account, name, jwk, body, hsm);
+ }
+
+ Response importKey(AzureRequest req, String account, String name, boolean hsm) {
+ Response invalid = validateKeyName(name);
+ if (invalid != null) {
+ return invalid;
+ }
+ Map body = parseBody(req);
+ @SuppressWarnings("unchecked")
+ Map supplied = body.get("key") instanceof Map, ?> m
+ ? (Map) m : new LinkedHashMap<>();
+
+ Map jwk;
+ try {
+ jwk = KeyVaultCrypto.importJwk(supplied);
+ } catch (KeyVaultCrypto.CryptoException e) {
+ return kvError(400, "BadParameter", e.getMessage());
+ }
+ jwk.put("hsm", Boolean.parseBoolean(String.valueOf(body.getOrDefault("HSM", false))));
+ jwk.put("tags", stringMap(body.get("tags")));
+
+ return storeNewKey(req, account, name, jwk, body, hsm);
+ }
+
+ private Response storeNewKey(AzureRequest req, String account, String name,
+ Map jwk, Map body, boolean hsm) {
+ // Recreating a name that is soft-deleted (not purged) is a 409 until it is recovered or purged.
+ if (store.get(deletedKeyKey(account, name, hsm)).isPresent()) {
+ return kvError(409, "Conflict",
+ "A key with (name/id) " + name + " was recently deleted and must be recovered or purged first.");
+ }
+ @SuppressWarnings("unchecked")
+ Map attrs = body.get("attributes") instanceof Map, ?> m
+ ? (Map) m : new LinkedHashMap<>();
+
+ long now = Instant.now().getEpochSecond();
+ String versionId = newVersionId();
+
+ Map meta = new HashMap<>();
+ meta.put("version", versionId);
+ meta.put("created", String.valueOf(now));
+ meta.put("updated", String.valueOf(now));
+ try {
+ normalizeAttrMeta(meta, attrs);
+ } catch (IllegalArgumentException e) {
+ return kvError(400, "BadParameter", e.getMessage());
+ }
+
+ StoredObject versionObj = new StoredObject(keyVersionKey(account, name, versionId, hsm),
+ toBytes(jwk), meta, Instant.now(), newVersionId().substring(0, 16));
+ store.put(versionObj.key(), versionObj);
+
+ Map latestMeta = new HashMap<>(versionObj.metadata());
+ latestMeta.put("latestVersion", versionId);
+ String latestKey = keyLatestKey(account, name, hsm);
+ store.put(latestKey, new StoredObject(latestKey, versionObj.data(), latestMeta,
+ versionObj.lastModified(), versionObj.etag()));
+
+ return Response.ok(toJson(keyBundle(account, name, versionId, versionObj, hsm)),
+ "application/json").build();
+ }
+
+ // ── Read ───────────────────────────────────────────────────────────────────
+
+ Response getKey(String account, String name, boolean hsm) {
+ Optional opt = store.get(keyLatestKey(account, name, hsm));
+ if (opt.isEmpty()) {
+ return keyNotFound(name);
+ }
+ StoredObject obj = opt.get();
+ String versionId = obj.metadata().getOrDefault("latestVersion", obj.metadata().get("version"));
+ return Response.ok(toJson(keyBundle(account, name, versionId, obj, hsm)), "application/json").build();
+ }
+
+ Response getKeyVersion(String account, String name, String version, boolean hsm) {
+ if (store.get(deletedKeyKey(account, name, hsm)).isPresent()) {
+ return keyNotFound(name + "/" + version);
+ }
+ Optional opt = store.get(keyVersionKey(account, name, version, hsm));
+ if (opt.isEmpty()) {
+ return keyNotFound(name + "/" + version);
+ }
+ return Response.ok(toJson(keyBundle(account, name, version, opt.get(), hsm)), "application/json").build();
+ }
+
+ Response listKeys(String account, boolean hsm) {
+ String prefix = keysPrefix(account, hsm);
+ List