diff --git a/jsign-crypto/src/main/java/net/jsign/jca/AzureTrustedSigningService.java b/jsign-crypto/src/main/java/net/jsign/jca/AzureTrustedSigningService.java index ca7c46ee..18549132 100644 --- a/jsign-crypto/src/main/java/net/jsign/jca/AzureTrustedSigningService.java +++ b/jsign-crypto/src/main/java/net/jsign/jca/AzureTrustedSigningService.java @@ -30,6 +30,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import net.jsign.DigestAlgorithm; @@ -41,6 +42,10 @@ */ public class AzureTrustedSigningService implements SigningService { + // Dataplane documentation for 2023-06-15-preview: + // https://github.com/Azure/azure-rest-api-specs/blob/main/specification/trustedsigning/data-plane/TrustedSigning/preview/2023-06-15-preview/azure.developer.trustedsigning.json + private static final String API_VERSION = "2023-06-15-preview"; + /** Cache of certificate chains indexed by alias */ private final Map certificates = new HashMap<>(); @@ -144,42 +149,140 @@ private SignStatus sign(String account, String profile, String algorithm, byte[] request.put("signatureAlgorithm", algorithm); request.put("digest", Base64.getEncoder().encodeToString(data)); - Map response = client.post("/codesigningaccounts/" + account + "/certificateprofiles/" + profile + "/sign?api-version=2022-06-15-preview", JsonWriter.format(request)); + RESTClient.RESTResponse initialResponse = client.postResponse("/codesigningaccounts/" + account + "/certificateprofiles/" + profile + ":sign?api-version=" + API_VERSION, JsonWriter.format(request)); - String operationId = (String) response.get("operationId"); + Map statusResponse = initialResponse.getBody(); + String operationLocation = initialResponse.getHeader("operation-location"); + String operationId = readOperationId(statusResponse); + if (operationId == null) { + operationId = extractOperationId(operationLocation); + } - // poll until the operation is completed - long startTime = System.currentTimeMillis(); - int i = 0; - while (System.currentTimeMillis() - startTime < timeout * 1000) { - try { - Thread.sleep(Math.min(1000, 50 + 10 * i++)); - } catch (InterruptedException e) { - break; - } - response = client.get("/codesigningaccounts/" + account + "/certificateprofiles/" + profile + "/sign/" + operationId + "?api-version=2022-06-15-preview"); - String status = (String) response.get("status"); - if ("InProgress".equals(status)) { - continue; + String status = statusResponse != null ? (String) statusResponse.get("status") : null; + + if (!isSucceeded(status)) { + String statusResource = resolveStatusResource(account, profile, operationId, operationLocation); + + long startTime = System.currentTimeMillis(); + int i = 0; + while (System.currentTimeMillis() - startTime < timeout * 1000) { + try { + Thread.sleep(Math.min(1000, 50 + 10 * i++)); + } catch (InterruptedException e) { + break; + } + statusResponse = client.get(statusResource); + status = statusResponse != null ? (String) statusResponse.get("status") : null; + if (operationId == null) { + operationId = readOperationId(statusResponse); + } + if (isPending(status)) { + continue; + } + if (isSucceeded(status)) { + break; + } + + throw new IOException("Signing operation " + describeOperation(operationId) + " failed: " + status); } - if ("Succeeded".equals(status)) { - break; + + if (!isSucceeded(status)) { + throw new IOException("Signing operation " + describeOperation(operationId) + " timed out"); } + } + + SignStatus signStatus = buildSignStatus(statusResponse); + if (signStatus == null) { + throw new IOException("Signing operation " + describeOperation(operationId) + " returned no result"); + } + + return signStatus; + } - throw new IOException("Signing operation " + operationId + " failed: " + status); + private SignStatus buildSignStatus(Map response) { + if (response == null) { + return null; } - if (!"Succeeded".equals(response.get("status"))) { - throw new IOException("Signing operation " + operationId + " timed out"); + Map result = response; + Object resultNode = response.get("result"); + if (resultNode instanceof Map) { + result = (Map) resultNode; } - SignStatus status = new SignStatus(); - status.signature = Base64.getDecoder().decode((String) response.get("signature")); - status.signingCertificate = new String(Base64.getDecoder().decode((String) response.get("signingCertificate"))); + Object signatureValue = result.get("signature"); + Object certificateValue = result.get("signingCertificate"); + if (!(signatureValue instanceof String) || !(certificateValue instanceof String)) { + return null; + } + SignStatus status = new SignStatus(); + status.signature = Base64.getDecoder().decode((String) signatureValue); + status.signingCertificate = new String(Base64.getDecoder().decode((String) certificateValue)); return status; } + private String resolveStatusResource(String account, String profile, String operationId, String operationLocation) throws IOException { + if (operationLocation != null && !operationLocation.isEmpty()) { + return ensureApiVersion(operationLocation.trim()); + } + if (operationId != null && !operationId.isEmpty()) { + return "/codesigningaccounts/" + account + "/certificateprofiles/" + profile + "/sign/" + operationId + "?api-version=" + API_VERSION; + } + throw new IOException("Signing operation identifier not returned by Azure Trusted Signing"); + } + + private String ensureApiVersion(String resource) { + if (resource == null || resource.isEmpty() || resource.contains("api-version=")) { + return resource; + } + return resource + (resource.contains("?") ? "&" : "?") + "api-version=" + API_VERSION; + } + + private String extractOperationId(String operationLocation) { + if (operationLocation == null || operationLocation.isEmpty()) { + return null; + } + String value = operationLocation; + int queryIndex = value.indexOf('?'); + if (queryIndex >= 0) { + value = value.substring(0, queryIndex); + } + int slashIndex = value.lastIndexOf('/'); + if (slashIndex >= 0 && slashIndex < value.length() - 1) { + return value.substring(slashIndex + 1); + } + return null; + } + + private String readOperationId(Map response) { + if (response == null) { + return null; + } + Object value = response.get("operationId"); + if (value == null) { + value = response.get("id"); + } + return value instanceof String ? (String) value : null; + } + + private boolean isPending(String status) { + String normalized = normalizeStatus(status); + return normalized == null || "INPROGRESS".equals(normalized) || "RUNNING".equals(normalized) || "NOTSTARTED".equals(normalized); + } + + private boolean isSucceeded(String status) { + return "SUCCEEDED".equals(normalizeStatus(status)); + } + + private String normalizeStatus(String status) { + return status == null ? null : status.replaceAll("\\s+", "").toUpperCase(Locale.ROOT); + } + + private String describeOperation(String operationId) { + return operationId != null ? operationId : "unknown"; + } + private static class SignStatus { public byte[] signature; public String signingCertificate; diff --git a/jsign-crypto/src/main/java/net/jsign/jca/RESTClient.java b/jsign-crypto/src/main/java/net/jsign/jca/RESTClient.java index a3d5cb8f..39d59f2d 100644 --- a/jsign-crypto/src/main/java/net/jsign/jca/RESTClient.java +++ b/jsign-crypto/src/main/java/net/jsign/jca/RESTClient.java @@ -17,10 +17,12 @@ package net.jsign.jca; import java.io.IOException; +import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -70,6 +72,10 @@ public RESTClient errorHandler(Function, String> errorHandler) { return query("GET", resource, null, null); } + public RESTResponse getResponse(String resource) throws IOException { + return request("GET", resource, null, null); + } + public Map post(String resource, String body) throws IOException { return query("POST", resource, body, null); } @@ -78,6 +84,14 @@ public RESTClient errorHandler(Function, String> errorHandler) { return query("POST", resource, body, headers); } + public RESTResponse postResponse(String resource, String body) throws IOException { + return request("POST", resource, body, null); + } + + public RESTResponse postResponse(String resource, String body, Map headers) throws IOException { + return request("POST", resource, body, headers); + } + public Map post(String resource, Map params) throws IOException { return post(resource, params, false); } @@ -124,6 +138,10 @@ public RESTClient errorHandler(Function, String> errorHandler) { } private Map query(String method, String resource, String body, Map headers) throws IOException { + return request(method, resource, body, headers).getBody(); + } + + private RESTResponse request(String method, String resource, String body, Map headers) throws IOException { URL url = new URL(resource.startsWith("http") ? resource : endpoint + resource); log.finest(method + " " + url); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); @@ -167,20 +185,26 @@ public RESTClient errorHandler(Function, String> errorHandler) { log.finest("Content-Type: " + contentType); if (responseCode < 400) { - byte[] binaryResponse = IOUtils.toByteArray(conn.getInputStream()); + InputStream input = conn.getInputStream(); + byte[] binaryResponse = input != null ? IOUtils.toByteArray(input) : new byte[0]; String response = new String(binaryResponse, StandardCharsets.UTF_8); log.finest("Content-Length: " + binaryResponse.length); log.finest("Content:\n" + response); log.finest(""); - Object value = JsonReader.jsonToJava(response); - if (value instanceof Map) { - return (Map) value; - } else { - Map map = new HashMap<>(); - map.put("result", value); - return map; + Map responseBody = Collections.emptyMap(); + if (!response.trim().isEmpty()) { + Object value = JsonReader.jsonToJava(response); + if (value instanceof Map) { + responseBody = (Map) value; + } else { + Map map = new HashMap<>(); + map.put("result", value); + responseBody = map; + } } + + return new RESTResponse(responseCode, conn.getHeaderFields(), responseBody, binaryResponse); } else { String error = conn.getErrorStream() != null ? IOUtils.toString(conn.getErrorStream(), StandardCharsets.UTF_8) : ""; if (conn.getErrorStream() != null) { @@ -193,4 +217,47 @@ public RESTClient errorHandler(Function, String> errorHandler) { } } } + + static class RESTResponse { + private final int statusCode; + private final Map> headers; + private final Map body; + private final byte[] rawBody; + + RESTResponse(int statusCode, Map> headers, Map body, byte[] rawBody) { + this.statusCode = statusCode; + this.headers = headers != null ? headers : Collections.emptyMap(); + this.body = body != null ? body : Collections.emptyMap(); + this.rawBody = rawBody != null ? rawBody : new byte[0]; + } + + public int getStatusCode() { + return statusCode; + } + + public Map> getHeaders() { + return headers; + } + + public Map getBody() { + return body; + } + + public byte[] getRawBody() { + return rawBody; + } + + public String getHeader(String name) { + if (name == null) { + return null; + } + for (Map.Entry> entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)) { + List values = entry.getValue(); + return values != null && !values.isEmpty() ? values.get(0) : null; + } + } + return null; + } + } } diff --git a/jsign-crypto/src/test/java/net/jsign/jca/AzureTrustedSigningServiceTest.java b/jsign-crypto/src/test/java/net/jsign/jca/AzureTrustedSigningServiceTest.java index 676150ed..1a5d29a6 100644 --- a/jsign-crypto/src/test/java/net/jsign/jca/AzureTrustedSigningServiceTest.java +++ b/jsign-crypto/src/test/java/net/jsign/jca/AzureTrustedSigningServiceTest.java @@ -16,14 +16,19 @@ package net.jsign.jca; -import java.io.FileReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; import java.security.GeneralSecurityException; import java.security.KeyStoreException; import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import com.cedarsoftware.util.io.JsonReader; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -55,15 +60,16 @@ public void testGetAliases() throws Exception { public void testGetCertificateChain() throws Exception { onRequest() .havingMethodEqualTo("POST") - .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile:sign") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(202) - .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}"); + .withHeader("operation-location", "http://localhost:" + port() + "/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign/1f234bd9-16cf-4283-9ee6-a460d31207bb?api-version=2023-06-15-preview") + .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}"); onRequest() .havingMethodEqualTo("GET") .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign/1f234bd9-16cf-4283-9ee6-a460d31207bb") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(200) .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}") @@ -72,7 +78,7 @@ public void testGetCertificateChain() throws Exception { .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}") .thenRespond() .withStatus(200) - .withBody(new FileReader("target/test-classes/services/trustedsigning-sign.json")); + .withBody(loadSignSuccessResponse()); SigningService service = new AzureTrustedSigningService("http://localhost:" + port(), "token"); Certificate[] chain = service.getCertificateChain("MyAccount/MyProfile"); @@ -88,8 +94,8 @@ public void testGetCertificateChain() throws Exception { public void testGetCertificateChainWithError() { onRequest() .havingMethodEqualTo("POST") - .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile:sign") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(403); @@ -112,15 +118,16 @@ public void testGetPrivateKey() throws Exception { public void testSign() throws Exception { onRequest() .havingMethodEqualTo("POST") - .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile:sign") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(202) + .withHeader("operation-location", "http://localhost:" + port() + "/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign/1f234bd9-16cf-4283-9ee6-a460d31207bb?api-version=2023-06-15-preview") .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}"); onRequest() .havingMethodEqualTo("GET") .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign/1f234bd9-16cf-4283-9ee6-a460d31207bb") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(200) .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}") @@ -129,7 +136,7 @@ public void testSign() throws Exception { .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}") .thenRespond() .withStatus(200) - .withBody(new FileReader("target/test-classes/services/trustedsigning-sign.json")); + .withBody(loadSignSuccessResponse()); AzureTrustedSigningService service = new AzureTrustedSigningService("http://localhost:" + port(), "token"); SigningServicePrivateKey privateKey = service.getPrivateKey("MyAccount/MyProfile", null); @@ -144,15 +151,16 @@ public void testSign() throws Exception { public void testSignWithTimeout() throws Exception { onRequest() .havingMethodEqualTo("POST") - .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile:sign") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(202) + .withHeader("operation-location", "http://localhost:" + port() + "/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign/1f234bd9-16cf-4283-9ee6-a460d31207bb?api-version=2023-06-15-preview") .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}"); onRequest() .havingMethodEqualTo("GET") .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign/1f234bd9-16cf-4283-9ee6-a460d31207bb") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(200) .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}"); @@ -169,15 +177,16 @@ public void testSignWithTimeout() throws Exception { public void testSignWithFailure() throws Exception { onRequest() .havingMethodEqualTo("POST") - .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile:sign") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(202) + .withHeader("operation-location", "http://localhost:" + port() + "/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign/1f234bd9-16cf-4283-9ee6-a460d31207bb?api-version=2023-06-15-preview") .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"InProgress\",\"signature\":null,\"signingCertificate\":null}"); onRequest() .havingMethodEqualTo("GET") .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign/1f234bd9-16cf-4283-9ee6-a460d31207bb") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(200) .withBody("{\"operationId\":\"1f234bd9-16cf-4283-9ee6-a460d31207bb\",\"status\":\"Failed\",\"signature\":null,\"signingCertificate\":null}"); @@ -202,8 +211,8 @@ public void testSignWithInvalidAlgorithm() throws Exception { public void testSignWithAuthorizationError() throws Exception { onRequest() .havingMethodEqualTo("POST") - .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile/sign") - .havingQueryStringEqualTo("api-version=2022-06-15-preview") + .havingPathEqualTo("/codesigningaccounts/MyAccount/certificateprofiles/MyProfile:sign") + .havingQueryStringEqualTo("api-version=2023-06-15-preview") .respond() .withStatus(404) .withContentType("application/json") @@ -215,4 +224,17 @@ public void testSignWithAuthorizationError() throws Exception { Exception e = assertThrows(GeneralSecurityException.class, () -> service.sign(privateKey, "SHA256withRSA", "Hello".getBytes())); assertEquals("message", "InternalError - Response status code does not indicate success: 403 (Forbidden).", e.getCause().getMessage()); } + + private String loadSignSuccessResponse() throws Exception { + String json = new String(Files.readAllBytes(Paths.get("target/test-classes/services/trustedsigning-sign.json")), StandardCharsets.UTF_8); + Map response = (Map) JsonReader.jsonToJava(json); + Map payload = new LinkedHashMap<>(); + payload.put("operationId", response.get("operationId")); + payload.put("status", response.get("status")); + Map result = new LinkedHashMap<>(); + result.put("signature", response.get("signature")); + result.put("signingCertificate", response.get("signingCertificate")); + payload.put("result", result); + return JsonWriter.format(payload); + } }