Skip to content
149 changes: 126 additions & 23 deletions jsign-crypto/src/main/java/net/jsign/jca/AzureTrustedSigningService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<String, Certificate[]> certificates = new HashMap<>();

Expand Down Expand Up @@ -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<String, ?> 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<String, ?> 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<String, ?> response) {
if (response == null) {
return null;
}

if (!"Succeeded".equals(response.get("status"))) {
throw new IOException("Signing operation " + operationId + " timed out");
Map<String, ?> result = response;
Object resultNode = response.get("result");
if (resultNode instanceof Map) {
result = (Map<String, ?>) 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<String, ?> 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;
Expand Down
83 changes: 75 additions & 8 deletions jsign-crypto/src/main/java/net/jsign/jca/RESTClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -70,6 +72,10 @@ public RESTClient errorHandler(Function<Map<String, ?>, String> errorHandler) {
return query("GET", resource, null, null);
}

public RESTResponse getResponse(String resource) throws IOException {
return request("GET", resource, null, null);
}

public Map<String, ?> post(String resource, String body) throws IOException {
return query("POST", resource, body, null);
}
Expand All @@ -78,6 +84,14 @@ public RESTClient errorHandler(Function<Map<String, ?>, 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<String, String> headers) throws IOException {
return request("POST", resource, body, headers);
}

public Map<String, ?> post(String resource, Map<String, String> params) throws IOException {
return post(resource, params, false);
}
Expand Down Expand Up @@ -124,6 +138,10 @@ public RESTClient errorHandler(Function<Map<String, ?>, String> errorHandler) {
}

private Map<String, ?> query(String method, String resource, String body, Map<String, String> headers) throws IOException {
return request(method, resource, body, headers).getBody();
}

private RESTResponse request(String method, String resource, String body, Map<String, String> headers) throws IOException {
URL url = new URL(resource.startsWith("http") ? resource : endpoint + resource);
log.finest(method + " " + url);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
Expand Down Expand Up @@ -167,20 +185,26 @@ public RESTClient errorHandler(Function<Map<String, ?>, 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<String, Object> map = new HashMap<>();
map.put("result", value);
return map;
Map<String, ?> responseBody = Collections.emptyMap();
if (!response.trim().isEmpty()) {
Object value = JsonReader.jsonToJava(response);
if (value instanceof Map) {
responseBody = (Map) value;
} else {
Map<String, Object> 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) {
Expand All @@ -193,4 +217,47 @@ public RESTClient errorHandler(Function<Map<String, ?>, String> errorHandler) {
}
}
}

static class RESTResponse {
private final int statusCode;
private final Map<String, List<String>> headers;
private final Map<String, ?> body;
private final byte[] rawBody;

RESTResponse(int statusCode, Map<String, List<String>> headers, Map<String, ?> 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<String, List<String>> getHeaders() {
return headers;
}

public Map<String, ?> getBody() {
return body;
}

public byte[] getRawBody() {
return rawBody;
}

public String getHeader(String name) {
if (name == null) {
return null;
}
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)) {
List<String> values = entry.getValue();
return values != null && !values.isEmpty() ? values.get(0) : null;
}
}
return null;
}
}
}
Loading