Skip to content

Commit

Permalink
Azure Trusted Signing support
Browse files Browse the repository at this point in the history
  • Loading branch information
ebourg committed Apr 22, 2024
1 parent 5d317da commit 00a4004
Show file tree
Hide file tree
Showing 16 changed files with 575 additions and 4 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Jsign is free to use and licensed under the [Apache License version 2.0](https:/
* Cloud key management systems:
* [AWS KMS](https://aws.amazon.com/kms/)
* [Azure Key Vault](https://azure.microsoft.com/services/key-vault/)
* [Azure Trusted Signing](https://learn.microsoft.com/en-us/azure/trusted-signing/)
* [DigiCert ONE](https://one.digicert.com)
* [Google Cloud KMS](https://cloud.google.com/security-key-management)
* [HashiCorp Vault](https://www.vaultproject.io/)
Expand All @@ -51,6 +52,7 @@ See https://ebourg.github.io/jsign for more information.

#### Version 6.1 (in development)

* The Azure Trusted Signing service has been integrated
* The Oracle Cloud signing service has been integrated
* Signing of NuGet packages has been implemented (contributed by Sebastian Stamm)
* Jsign now checks if the certificate subject matches the app manifest publisher before signing APPX/MSIX packages
Expand Down
32 changes: 32 additions & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ <h3 id="features">Features</h3>
<ul>
<li><a href="https://aws.amazon.com/kms/">AWS KMS</a></li>
<li><a href="https://azure.microsoft.com/services/key-vault/">Azure Key Vault</a></li>
<li><a href="https://learn.microsoft.com/en-us/azure/trusted-signing/">Azure Trusted Signing</a></li>
<li><a href="https://one.digicert.com">DigiCert ONE</a></li>
<li><a href="https://cloud.google.com/security-key-management">Google Cloud KMS</a></li>
<li><a href="https://www.vaultproject.io">HashiCorp Vault</a></li>
Expand Down Expand Up @@ -198,6 +199,7 @@ <h4 id="attributes" class="mobile-only">Attributes</h4>
<li><code>GOOGLECLOUD</code>: Google Cloud KMS</li>
<li><code>HASHICORPVAULT</code>: Google Cloud KMS via HashiCorp Vault</li>
<li><code>ORACLECLOUD</code>: Oracle Cloud Key Management Service</li>
<li><code>TRUSTEDSIGNING</code>: Azure Trusted Signing</li>
</ul>
</td>
<td class="required">No, automatically detected for file based keystores.</td>
Expand Down Expand Up @@ -472,6 +474,7 @@ <h3 id="cli">Command Line Tool</h3>
- GOOGLECLOUD: Google Cloud KMS
- HASHICORPVAULT: Google Cloud KMS via HashiCorp Vault
- ORACLECLOUD: Oracle Cloud Key Management Service
- TRUSTEDSIGNING: Azure Trusted Signing
-a,--alias &lt;NAME> The alias of the certificate used for signing in the keystore.
--keypass &lt;PASSWORD> The password of the private key. When using a keystore,
this parameter can be omitted if the keystore shares the
Expand Down Expand Up @@ -641,6 +644,35 @@ <h4 id="example-azurekeyvault">Signing with Azure Key Vault</h4>

<p>The Azure account used must have the <em>"Key Vault Crypto User"</em> and <em>"Key Vault Certificate User"</em> roles.</p>

<h4 id="example-trustedsigning">Signing with Azure Trusted Signing</h4>

<p>With the Azure <a href="https://learn.microsoft.com/en-us/azure/trusted-signing/overview">Trusted Signing</a> service
the <code>keystore</code> parameter specifies the endpoint URI, and the <code>alias</code> combines the account name and
the certificate profile. The Azure API access token is used as the keystore password.</p>

<pre>
jsign --storetype TRUSTEDSIGNING \
--keystore weu.codesigning.azure.net \
--storepass &lt;api-access-token&gt; \
--alias &lt;account&gt;/&lt;profile&gt; application.exe
</pre>

<p>The access token can be obtained with the <a href="https://learn.microsoft.com/en-us/cli/azure/">Azure CLI</a>:</p>

<pre>
az account get-access-token --resource https://codesigning.azure.net
</pre>

<p>The Azure account used must have the <em>"Code Signing Certificate Profile Signer"</em> role.</p>

<p>The certificates issued by Azure Trusted Signing have a lifetime of 3 days only, and timestamping is necessary to
ensure the long term validity of the signature. For this reason timestamping is automatically enabled when signing
with this service.</p>

<p>Implementation note: Jsign performs an extra call to the signing API to retrieve the current certificate chain before
signing. When signing multiple files it's recommended to invoke Jsign only once with the list of files to avoid doubling
the quota usage.</p>

<h4 id="example-digicertone">Signing with DigiCert ONE</h4>

<p>Certificates and keys stored in the <a href="https://one.digicert.com">DigiCert ONE</a> Secure Software Manager
Expand Down
3 changes: 2 additions & 1 deletion jsign-cli/src/main/java/net/jsign/JsignCLI.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ public static void main(String... args) {
+ "- ESIGNER: SSL.com eSigner\n"
+ "- GOOGLECLOUD: Google Cloud KMS\n"
+ "- HASHICORPVAULT: Google Cloud KMS via HashiCorp Vault\n"
+ "- ORACLECLOUD: Oracle Cloud Key Management Service\n").build());
+ "- ORACLECLOUD: Oracle Cloud Key Management Service\n"
+ "- TRUSTEDSIGNING: Azure Trusted Signing\n").build());
options.addOption(Option.builder("a").hasArg().longOpt(PARAM_ALIAS).argName("NAME").desc("The alias of the certificate used for signing in the keystore.").build());
options.addOption(Option.builder().hasArg().longOpt(PARAM_KEYPASS).argName("PASSWORD").desc("The password of the private key. When using a keystore, this parameter can be omitted if the keystore shares the same password.").build());
options.addOption(Option.builder().hasArg().longOpt(PARAM_KEYFILE).argName("FILE").desc("The file containing the private key. PEM and PVK files are supported. ").type(File.class).build());
Expand Down
25 changes: 25 additions & 0 deletions jsign-core/src/main/java/net/jsign/KeyStoreType.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import net.jsign.jca.AmazonCredentials;
import net.jsign.jca.AmazonSigningService;
import net.jsign.jca.AzureKeyVaultSigningService;
import net.jsign.jca.AzureTrustedSigningService;
import net.jsign.jca.DigiCertOneSigningService;
import net.jsign.jca.ESignerSigningService;
import net.jsign.jca.GoogleCloudSigningService;
Expand Down Expand Up @@ -469,6 +470,30 @@ Provider getProvider(KeyStoreBuilder params) {
}
return new SigningServiceJcaProvider(new OracleCloudSigningService(credentials, getCertificateStore(params)));
}
},

/**
* Azure Trusted Signing Service. The keystore parameter specifies the API endpoint (for example
* <code>weu.codesigning.azure.net</code>). The Azure API access token is used as the keystore password,
* it can be obtained using the Azure CLI with:
*
* <pre> az account get-access-token --resource https://codesigning.azure.net</pre>
*/
TRUSTEDSIGNING(false, false, false) {
@Override
void validate(KeyStoreBuilder params) {
if (params.keystore() == null) {
throw new IllegalArgumentException("keystore " + params.parameterName() + " must specify the Azure endpoint (<region>.codesigning.azure.net)");
}
if (params.storepass() == null) {
throw new IllegalArgumentException("storepass " + params.parameterName() + " must specify the Azure API access token");
}
}

@Override
Provider getProvider(KeyStoreBuilder params) {
return new SigningServiceJcaProvider(new AzureTrustedSigningService(params.keystore(), params.storepass()));
}
};


Expand Down
7 changes: 7 additions & 0 deletions jsign-core/src/main/java/net/jsign/SignerHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,13 @@ private AuthenticodeSigner build() throws SignerException {
} catch (Exception e) {
throw new SignerException("Couldn't initialize proxy", e);
}

// enable timestamping with Azure Trusted Signing
if (tsaurl == null && storetype == KeyStoreType.TRUSTEDSIGNING) {
tsaurl = "http://timestamp.acs.microsoft.com/";
tsmode = TimestampingMode.RFC3161.name();
tsretries = 3;
}

// configure the signer
return new AuthenticodeSigner(chain, privateKey)
Expand Down
189 changes: 189 additions & 0 deletions jsign-core/src/main/java/net/jsign/jca/AzureTrustedSigningService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* Copyright 2024 Emmanuel Bourg
*
* 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 net.jsign.jca;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.InvalidAlgorithmParameterException;
import java.security.KeyStoreException;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.cedarsoftware.util.io.JsonWriter;

import net.jsign.DigestAlgorithm;

/**
* Signing service using the Azure Trusted Signing API.
*
* @since 6.1
*/
public class AzureTrustedSigningService implements SigningService {

/** Cache of certificate chains indexed by alias */
private final Map<String, Certificate[]> certificates = new HashMap<>();

private final RESTClient client;

/** Timeout in seconds for the signing operation */
private long timeout = 60;

/**
* Mapping between Java and Azure signing algorithms.
* @see <a href="https://docs.microsoft.com/en-us/rest/api/keyvault/sign/sign#jsonwebkeysignaturealgorithm">Key Vault API - JonWebKeySignatureAlgorithm</a>
*/
private final Map<String, String> algorithmMapping = new HashMap<>();
{
algorithmMapping.put("SHA256withRSA", "RS256");
algorithmMapping.put("SHA384withRSA", "RS384");
algorithmMapping.put("SHA512withRSA", "RS512");
algorithmMapping.put("SHA256withECDSA", "ES256");
algorithmMapping.put("SHA384withECDSA", "ES384");
algorithmMapping.put("SHA512withECDSA", "ES512");
algorithmMapping.put("SHA256withRSA/PSS", "PS256");
algorithmMapping.put("SHA384withRSA/PSS", "PS384");
algorithmMapping.put("SHA512withRSA/PSS", "PS512");
}

public AzureTrustedSigningService(String endpoint, String token) {
if (!endpoint.startsWith("http")) {
endpoint = "https://" + endpoint;
}
client = new RESTClient(endpoint, conn -> conn.setRequestProperty("Authorization", "Bearer " + token));
}

void setTimeout(int timeout) {
this.timeout = timeout;
}

@Override
public String getName() {
return "TrustedSigning";
}

@Override
public List<String> aliases() throws KeyStoreException {
return new ArrayList<>();
}

@Override
public Certificate[] getCertificateChain(String alias) throws KeyStoreException {
if (!certificates.containsKey(alias)) {
try {
String account = alias.substring(0, alias.indexOf('/'));
String profile = alias.substring(alias.indexOf('/') + 1);
SignStatus status = sign(account, profile, "RS256", new byte[32]);
certificates.put(alias, status.getCertificateChain().toArray(new Certificate[0]));
} catch (Exception e) {
throw new KeyStoreException("Unable to retrieve the certificate chain '" + alias + "'", e);
}
}

return certificates.get(alias);
}

@Override
public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException {
return new SigningServicePrivateKey(alias, "RSA", this);
}

@Override
public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException {
String alg = algorithmMapping.get(algorithm);
if (alg == null) {
throw new InvalidAlgorithmParameterException("Unsupported signing algorithm: " + algorithm);
}

DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with")));
data = digestAlgorithm.getMessageDigest().digest(data);

String alias = privateKey.getId();
String account = alias.substring(0, alias.indexOf('/'));
String profile = alias.substring(alias.indexOf('/') + 1);
try {
SignStatus status = sign(account, profile, alg, data);
return status.signature;
} catch (IOException e) {
throw new GeneralSecurityException(e);
}
}

private SignStatus sign(String account, String profile, String algorithm, byte[] data) throws IOException {
Map<String, Object> request = new HashMap<>();
request.put("signatureAlgorithm", algorithm);
request.put("digest", Base64.getEncoder().encodeToString(data));

Map<String, Object> args = new HashMap<>();
args.put(JsonWriter.TYPE, "false");

Map<String, ?> response = client.post("/codesigningaccounts/" + account + "/certificateprofiles/" + profile + "/sign?api-version=2022-06-15-preview", JsonWriter.objectToJson(request, args));

String operationId = (String) response.get("operationId");

// 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;
}
if ("Succeeded".equals(status)) {
break;
}

throw new IOException("Signing operation " + operationId + " failed: " + status);
}

if (!"Succeeded".equals(response.get("status"))) {
throw new IOException("Signing operation " + operationId + " timed out");
}

SignStatus status = new SignStatus();
status.signature = Base64.getDecoder().decode((String) response.get("signature"));
status.signingCertificate = new String(Base64.getDecoder().decode((String) response.get("signingCertificate")));

return status;
}

private static class SignStatus {
public byte[] signature;
public String signingCertificate;

public Collection<? extends Certificate> getCertificateChain() throws CertificateException {
byte[] cerbin = Base64.getMimeDecoder().decode(signingCertificate);

CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return certificateFactory.generateCertificates(new ByteArrayInputStream(cerbin));
}
}
}
5 changes: 5 additions & 0 deletions jsign-core/src/main/java/net/jsign/jca/RESTClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import java.util.function.Consumer;

import com.cedarsoftware.util.io.JsonReader;
import com.cedarsoftware.util.io.JsonWriter;
import org.apache.commons.io.IOUtils;

class RESTClient {
Expand Down Expand Up @@ -136,6 +137,10 @@ private String getErrorMessage(Map<String, ?> response) {
String error = (String) response.get("__type");
String description = (String) response.get("message");
message.append(error).append(": ").append(description);
} else if (response.containsKey("title") && response.containsKey("errors")) {
// error from Azure Code Signing API
String errors = JsonWriter.objectToJson(response.get("errors"));
message.append(response.get("status")).append(" - ").append(response.get("title")).append(": ").append(errors);
} else if (response.containsKey("code") && response.containsKey("message")) {
// error from OCI API
message.append(response.get("code")).append(": ").append(response.get("message"));
Expand Down
26 changes: 26 additions & 0 deletions jsign-core/src/test/java/net/jsign/KeyStoreBuilderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,32 @@ public void testBuildOracleCloud() throws Exception {
assertNotNull("keystore", keystore);
}

@Test
public void testBuildTrustedSigning() throws Exception {
KeyStoreBuilder builder = new KeyStoreBuilder().storetype(TRUSTEDSIGNING);

try {
builder.build();
fail("Exception not thrown");
} catch (IllegalArgumentException e) {
assertEquals("message", "keystore parameter must specify the Azure endpoint (<region>.codesigning.azure.net)", e.getMessage());
}

builder.keystore("https://weu.codesigning.azure.net");

try {
builder.build();
fail("Exception not thrown");
} catch (IllegalArgumentException e) {
assertEquals("message", "storepass parameter must specify the Azure API access token", e.getMessage());
}

builder.storepass("0123456789ABCDEF");

KeyStore keystore = builder.build();
assertNotNull("keystore", keystore);
}

@Test
public void testBuildJKS() throws Exception {
KeyStoreBuilder builder = new KeyStoreBuilder().storetype(JKS);
Expand Down
Loading

0 comments on commit 00a4004

Please sign in to comment.