diff --git a/NetLicensingClient/pom.xml b/NetLicensingClient/pom.xml
index 4429f8db..4568bfef 100644
--- a/NetLicensingClient/pom.xml
+++ b/NetLicensingClient/pom.xml
@@ -19,6 +19,10 @@
com.helger
ph-xmldsig
+
+ com.nimbusds
+ nimbus-jose-jwt
+
com.sun.xml.bind
jaxb-impl
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/domain/Constants.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/domain/Constants.java
index c3be1798..315b4c7a 100644
--- a/NetLicensingClient/src/main/java/com/labs64/netlicensing/domain/Constants.java
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/domain/Constants.java
@@ -213,6 +213,7 @@ public static final class Shop {
public static final class Validation {
public static final String DRY_RUN = "dryRun";
public static final String FOR_OFFLINE_USE = "forOfflineUse";
+ public static final String NONCE = "nonce";
}
public static final class ValidationResult {
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/domain/vo/ValidationParameters.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/domain/vo/ValidationParameters.java
index c803a1f7..5808c647 100644
--- a/NetLicensingClient/src/main/java/com/labs64/netlicensing/domain/vo/ValidationParameters.java
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/domain/vo/ValidationParameters.java
@@ -11,6 +11,7 @@ public class ValidationParameters {
private Map licenseeProperties;
private Boolean dryRun;
private Boolean forOfflineUse;
+ private String nonce;
private Map> parameters;
/**
@@ -107,6 +108,20 @@ public Boolean isForOfflineUse() {
return forOfflineUse;
}
+ /**
+ * Sets an optional request value that must be returned as the signed validation response identifier.
+ *
+ * @param nonce
+ * correlation value, up to 255 characters
+ */
+ public void setNonce(final String nonce) {
+ this.nonce = nonce;
+ }
+
+ public String getNonce() {
+ return nonce;
+ }
+
public Map> getParameters() {
if (parameters == null) {
parameters = new HashMap<>();
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/exception/BadSignatureException.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/exception/BadSignatureException.java
index 3ee671fd..4c95c878 100644
--- a/NetLicensingClient/src/main/java/com/labs64/netlicensing/exception/BadSignatureException.java
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/exception/BadSignatureException.java
@@ -29,4 +29,16 @@ public BadSignatureException(final String msg) {
super(msg);
}
+ /**
+ * Construct a BadSignatureException with the specified detail message and cause.
+ *
+ * @param msg
+ * the detail message
+ * @param cause
+ * the cause exception
+ */
+ public BadSignatureException(final String msg, final Throwable cause) {
+ super(msg, cause);
+ }
+
}
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/provider/RestProviderImpl.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/provider/RestProviderImpl.java
index 0577a4de..88eb361e 100644
--- a/NetLicensingClient/src/main/java/com/labs64/netlicensing/provider/RestProviderImpl.java
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/provider/RestProviderImpl.java
@@ -111,6 +111,7 @@ public RestResponse call(final String httpMethod, final String u
final RestResponse restResponse = new RestResponse<>();
restResponse.setStatusCode(response.statusCode());
restResponse.setHeaders(response.headers().map());
+ restResponse.setRawEntity(response.body());
restResponse.setEntity(readEntity(response.body(), response.statusCode(), responseType));
return restResponse;
} catch (final IOException | InterruptedException e) {
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/provider/RestResponse.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/provider/RestResponse.java
index 13847c51..1b181c4a 100644
--- a/NetLicensingClient/src/main/java/com/labs64/netlicensing/provider/RestResponse.java
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/provider/RestResponse.java
@@ -34,6 +34,8 @@ public class RestResponse {
private T entity;
+ private String rawEntity;
+
public int getStatusCode() {
return this.statusCode;
}
@@ -68,4 +70,12 @@ public void setEntity(final T entity) {
this.entity = entity;
}
+ public String getRawEntity() {
+ return rawEntity;
+ }
+
+ public void setRawEntity(final String rawEntity) {
+ this.rawEntity = rawEntity;
+ }
+
}
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/service/NetLicensingService.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/service/NetLicensingService.java
index ae11047c..edde2fe2 100644
--- a/NetLicensingClient/src/main/java/com/labs64/netlicensing/service/NetLicensingService.java
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/service/NetLicensingService.java
@@ -25,6 +25,7 @@
import com.labs64.netlicensing.domain.vo.Context;
import com.labs64.netlicensing.domain.vo.MetaInfo;
import com.labs64.netlicensing.domain.vo.Page;
+import com.labs64.netlicensing.exception.BadSignatureException;
import com.labs64.netlicensing.exception.NetLicensingException;
import com.labs64.netlicensing.exception.RestException;
import com.labs64.netlicensing.exception.ServiceException;
@@ -33,6 +34,7 @@
import com.labs64.netlicensing.provider.RestResponse;
import com.labs64.netlicensing.schema.SchemaFunction;
import com.labs64.netlicensing.schema.context.Netlicensing;
+import com.labs64.netlicensing.util.AgentResponseSignatureVerifier;
import com.labs64.netlicensing.util.CheckUtils;
import com.labs64.netlicensing.util.SignatureUtils;
@@ -170,8 +172,16 @@ void delete(final Context context, final String urlTemplate, final Map queryParams) throws NetLicensingException {
+ return request(context, method, urlTemplate, request, queryParams, null);
+ }
+
+ Netlicensing request(final Context context, final HttpMethod method, final String urlTemplate,
+ final Form request, final Map queryParams, final String expectedLicensee)
+ throws NetLicensingException {
CheckUtils.paramNotNull(context, "context");
+ final String expectedNonce = request == null ? null : request.getParams().get(Constants.Validation.NONCE);
+
Form combinedRequest = request;
Map combinedQueryParams = queryParams;
if (StringUtils.isNotBlank(context.getVendorNumber())) {
@@ -190,21 +200,32 @@ public Netlicensing request(final Context context, final HttpMethod method, fina
final RestProviderImpl restProvider = new RestProviderImpl(context.getBaseUrl());
configure(restProvider, context);
-
- final RestResponse response = restProvider.call(method.name(), urlTemplate, combinedRequest, Netlicensing.class,
- combinedQueryParams);
+
+ final RestResponse response = restProvider.call(method.name(), urlTemplate, combinedRequest,
+ Netlicensing.class, combinedQueryParams);
if (!isErrorStatus(response.getStatusCode())) {
switch (response.getStatusCode()) {
- case 200:
- final Netlicensing netlicensing = response.getEntity();
- SignatureUtils.check(context, netlicensing);
- return netlicensing;
- case 204:
- return null;
- default:
- throw new RestException(String.format("Unsupported response status code %s.",
- response.getStatusCode()));
+ case 200:
+ final Netlicensing netlicensing = response.getEntity();
+ if (StringUtils.isNotEmpty(context.getPublicKey()) && netlicensing.getAgentSigning() != null) {
+ AgentResponseSignatureVerifier.verify(response.getRawEntity(), expectedLicensee,
+ context.getPublicKey());
+ } else {
+ SignatureUtils.check(context, netlicensing);
+ }
+
+ if (StringUtils.isNotBlank(expectedNonce) && !expectedNonce.equals(netlicensing.getId())) {
+ throw new BadSignatureException(
+ "Validation response identifier does not match the request nonce.");
+ }
+
+ return netlicensing;
+ case 204:
+ return null;
+ default:
+ throw new RestException(String.format("Unsupported response status code %s.",
+ response.getStatusCode()));
}
} else {
if (SchemaFunction.hasErrorInfos(response.getEntity())) {
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/service/ValidationService.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/service/ValidationService.java
index 04d5cdd8..82e84389 100644
--- a/NetLicensingClient/src/main/java/com/labs64/netlicensing/service/ValidationService.java
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/service/ValidationService.java
@@ -80,7 +80,7 @@ public static Netlicensing retrieveValidationFile(final Context context, final S
final NetLicensingService service = NetLicensingService.getInstance();
return service.request(context, HttpMethod.POST,
Constants.Licensee.ENDPOINT_PATH + "/" + number + "/" + Constants.Licensee.ENDPOINT_PATH_VALIDATE, form,
- null);
+ null, number);
}
/**
@@ -121,6 +121,9 @@ private static Form convertValidationParameters(final ValidationParameters valid
if (Boolean.TRUE.equals(validationParameters.isForOfflineUse())) {
form.param(Constants.Validation.FOR_OFFLINE_USE, Boolean.TRUE.toString());
}
+ if (StringUtils.isNotBlank(validationParameters.getNonce())) {
+ form.param(Constants.Validation.NONCE, validationParameters.getNonce());
+ }
int pmIndex = 0;
for (final Entry> productModuleValidationParams : validationParameters
.getParameters().entrySet()) {
@@ -144,4 +147,4 @@ private static ValidationResult convertValidationResult(final Netlicensing valid
return service.processResponse(meta, validationFile, ValidationResult.class);
}
}
-}
\ No newline at end of file
+}
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentResponseSignatureVerifier.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentResponseSignatureVerifier.java
new file mode 100644
index 00000000..80c44a25
--- /dev/null
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentResponseSignatureVerifier.java
@@ -0,0 +1,251 @@
+package com.labs64.netlicensing.util;
+
+import java.io.StringReader;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.spec.X509EncodedKeySpec;
+import java.time.Clock;
+import java.util.Base64;
+import java.util.Objects;
+
+import javax.xml.XMLConstants;
+import javax.xml.crypto.dsig.DigestMethod;
+import javax.xml.crypto.dsig.SignatureMethod;
+import javax.xml.crypto.dsig.Transform;
+import javax.xml.crypto.dsig.XMLSignatureFactory;
+import javax.xml.crypto.dsig.dom.DOMValidateContext;
+import javax.xml.parsers.DocumentBuilderFactory;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.InputSource;
+
+import com.labs64.netlicensing.exception.BadSignatureException;
+
+/**
+ * Verifies signed offline NetLicensing Agent validation responses.
+ */
+public final class AgentResponseSignatureVerifier {
+
+ private static final String CONTEXT_NAMESPACE = "http://netlicensing.labs64.com/schema/context";
+ private static final String XMLDSIG_NAMESPACE = "http://www.w3.org/2000/09/xmldsig#";
+ private static final String XML_SIGNATURE_ELEMENT = "Signature";
+ private static final String AGENT_SIGNING_ELEMENT = "agentSigning";
+ private static final String CERTIFICATE_ELEMENT = "certificate";
+ private static final String PUBLIC_KEY_ELEMENT = "publicKey";
+
+ private AgentResponseSignatureVerifier() {
+ }
+
+ /**
+ * Verify a signed Agent response using a Licensee API key public key.
+ *
+ * @param responseXml
+ * raw Agent validation response XML
+ * @param expectedLicensee
+ * Licensee number used for validation
+ * @param licenseeApiKeyPublicKey
+ * public key corresponding to the Licensee API
+ * key
+ * @throws BadSignatureException
+ * if any part of the response trust chain is
+ * invalid
+ */
+ public static void verify(final String responseXml, final String expectedLicensee,
+ final PublicKey licenseeApiKeyPublicKey) throws BadSignatureException {
+ verify(responseXml, expectedLicensee, licenseeApiKeyPublicKey, Clock.systemUTC());
+ }
+
+ /**
+ * Verify a signed Agent response using a PEM or Base64-encoded Licensee API key
+ * public key.
+ *
+ * @param responseXml
+ * raw Agent validation response XML
+ * @param expectedLicensee
+ * Licensee number used for validation
+ * @param licenseeApiKeyPublicKey
+ * PEM or Base64-encoded public key corresponding
+ * to the Licensee API key
+ * @throws BadSignatureException
+ * if any part of the response trust chain is
+ * invalid
+ */
+ public static void verify(final String responseXml, final String expectedLicensee,
+ final String licenseeApiKeyPublicKey) throws BadSignatureException {
+ try {
+ final PublicKey publicKey = SignatureUtils.readPublicKey(
+ SignatureUtils.cleansePublicKey(licenseeApiKeyPublicKey));
+ verify(responseXml, expectedLicensee, publicKey);
+ } catch (final BadSignatureException e) {
+ throw e;
+ } catch (final Exception e) {
+ throw failure("Licensee API key public key is malformed.", e);
+ }
+ }
+
+ static void verify(final String responseXml, final String expectedLicensee,
+ final PublicKey licenseeApiKeyPublicKey, final Clock clock) throws BadSignatureException {
+ try {
+ requireText(expectedLicensee, "Expected Licensee number");
+ Objects.requireNonNull(licenseeApiKeyPublicKey, "Licensee API key public key is missing.");
+ Objects.requireNonNull(clock, "Clock is missing.");
+
+ final Document document = parseSecurely(responseXml);
+ final Element root = document.getDocumentElement();
+ if (!CONTEXT_NAMESPACE.equals(root.getNamespaceURI()) || !"netlicensing".equals(root.getLocalName())) {
+ throw new IllegalArgumentException("Unexpected Agent validation response root.");
+ }
+
+ if (document.getElementsByTagNameNS(XMLDSIG_NAMESPACE, XML_SIGNATURE_ELEMENT).getLength() != 1) {
+ throw new IllegalArgumentException("Agent validation response must contain exactly one signature.");
+ }
+
+ final Element signature = requireSingleDirectChild(root, XMLDSIG_NAMESPACE, XML_SIGNATURE_ELEMENT);
+ enforceSignatureProfile(signature);
+
+ final Element agentSigning = requireSingleDirectChild(root, CONTEXT_NAMESPACE, AGENT_SIGNING_ELEMENT);
+ final String compactCertificate = requireSingleDirectChild(agentSigning, CONTEXT_NAMESPACE,
+ CERTIFICATE_ELEMENT).getTextContent().trim();
+ final String encodedPublicKey = requireSingleDirectChild(agentSigning, CONTEXT_NAMESPACE,
+ PUBLIC_KEY_ELEMENT)
+ .getTextContent().trim();
+ final AgentSigningCertificate certificate = AgentSigningCertificateVerifier.verify(compactCertificate,
+ licenseeApiKeyPublicKey);
+
+ if (!expectedLicensee.equals(certificate.licenseeNumber())) {
+ throw new IllegalArgumentException("Agent signing certificate Licensee scope does not match request.");
+ }
+
+ if (!certificate.isValidAt(clock.instant())) {
+ throw new IllegalArgumentException("Agent signing certificate is not currently valid.");
+ }
+
+ final PublicKey agentPublicKey = decodeRsaPublicKey(encodedPublicKey);
+ if (!MessageDigest.isEqual(decodeDigest(certificate.agentPublicKeyDigest()), digest(agentPublicKey))) {
+ throw new IllegalArgumentException("Agent signing public key does not match its certificate.");
+ }
+
+ final DOMValidateContext validationContext = new DOMValidateContext(agentPublicKey, signature);
+ validationContext.setProperty("org.jcp.xml.dsig.secureValidation", Boolean.TRUE);
+ if (!XMLSignatureFactory.getInstance("DOM").unmarshalXMLSignature(validationContext)
+ .validate(validationContext)) {
+ throw new IllegalArgumentException("Agent validation response signature is invalid.");
+ }
+ } catch (final Exception e) {
+ throw failure("Agent validation response signature verification failed.", e);
+ }
+ }
+
+ private static Document parseSecurely(final String responseXml) throws Exception {
+ requireText(responseXml, "Agent validation response");
+
+ final DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(true);
+ factory.setXIncludeAware(false);
+ factory.setExpandEntityReferences(false);
+ factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
+ factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
+ factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
+ factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
+ factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
+
+ return factory.newDocumentBuilder().parse(new InputSource(new StringReader(responseXml)));
+ }
+
+ private static PublicKey decodeRsaPublicKey(final String encodedPublicKey) {
+ try {
+ return KeyFactory.getInstance("RSA").generatePublic(
+ new X509EncodedKeySpec(Base64.getDecoder().decode(encodedPublicKey)));
+ } catch (final GeneralSecurityException | RuntimeException e) {
+ throw new IllegalArgumentException("Agent signing public key is malformed.", e);
+ }
+ }
+
+ private static byte[] digest(final PublicKey publicKey) {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(publicKey.getEncoded());
+ } catch (final NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 is unavailable.", e);
+ }
+ }
+
+ private static byte[] decodeDigest(final String digest) {
+ requireText(digest, "Agent public key digest");
+ try {
+ return Base64.getUrlDecoder().decode(digest);
+ } catch (final IllegalArgumentException e) {
+ throw new IllegalArgumentException("Agent public key digest is not valid Base64URL.", e);
+ }
+ }
+
+ private static void enforceSignatureProfile(final Element signature) {
+ final Element signatureMethod = requireSingleDescendant(signature, XMLDSIG_NAMESPACE, "SignatureMethod");
+ if (!SignatureMethod.RSA_SHA256.equals(signatureMethod.getAttribute("Algorithm"))) {
+ throw new IllegalArgumentException("Unexpected Agent response signature algorithm.");
+ }
+
+ final Element reference = requireSingleDescendant(signature, XMLDSIG_NAMESPACE, "Reference");
+ if (!"".equals(reference.getAttribute("URI"))) {
+ throw new IllegalArgumentException("Agent response signature must reference the document root.");
+ }
+
+ final Element digestMethod = requireSingleDescendant(reference, XMLDSIG_NAMESPACE, "DigestMethod");
+ if (!DigestMethod.SHA256.equals(digestMethod.getAttribute("Algorithm"))) {
+ throw new IllegalArgumentException("Unexpected Agent response digest algorithm.");
+ }
+
+ final NodeList transforms = reference.getElementsByTagNameNS(XMLDSIG_NAMESPACE, "Transform");
+ if (transforms.getLength() != 1
+ || !Transform.ENVELOPED.equals(((Element) transforms.item(0)).getAttribute("Algorithm"))) {
+ throw new IllegalArgumentException("Unexpected Agent response signature transforms.");
+ }
+ }
+
+ private static String requireText(final String value, final String name) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(name + " is missing.");
+ }
+ return value;
+ }
+
+ private static Element requireSingleDirectChild(final Element parent, final String namespace,
+ final String localName) {
+ Element found = null;
+ for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) {
+ if (node instanceof Element element && namespace.equals(element.getNamespaceURI())
+ && localName.equals(element.getLocalName())) {
+ if (found != null) {
+ throw new IllegalArgumentException("Duplicate '" + localName + "' element.");
+ }
+ found = element;
+ }
+ }
+ if (found == null) {
+ throw new IllegalArgumentException("Missing '" + localName + "' element.");
+ }
+ return found;
+ }
+
+ private static Element requireSingleDescendant(final Element parent, final String namespace,
+ final String localName) {
+ final NodeList nodes = parent.getElementsByTagNameNS(namespace, localName);
+ if (nodes.getLength() != 1) {
+ throw new IllegalArgumentException("Expected exactly one '" + localName + "' element.");
+ }
+ return (Element) nodes.item(0);
+ }
+
+ private static BadSignatureException failure(final String message, final Exception cause) {
+ if (cause instanceof BadSignatureException badSignatureException) {
+ return badSignatureException;
+ }
+ return new BadSignatureException(message, cause);
+ }
+
+}
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentSigningCertificate.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentSigningCertificate.java
new file mode 100644
index 00000000..45602264
--- /dev/null
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentSigningCertificate.java
@@ -0,0 +1,12 @@
+package com.labs64.netlicensing.util;
+
+import java.time.Instant;
+
+/** Verified authorization of one Agent signing key for one Licensee. */
+record AgentSigningCertificate(String agentPublicKeyDigest, String licenseeNumber, Instant notBefore,
+ Instant notAfter) {
+
+ boolean isValidAt(final Instant instant) {
+ return !instant.isBefore(notBefore) && instant.isBefore(notAfter);
+ }
+}
diff --git a/NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentSigningCertificateVerifier.java b/NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentSigningCertificateVerifier.java
new file mode 100644
index 00000000..288af56a
--- /dev/null
+++ b/NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentSigningCertificateVerifier.java
@@ -0,0 +1,122 @@
+package com.labs64.netlicensing.util;
+
+import java.security.PublicKey;
+import java.security.interfaces.RSAPublicKey;
+import java.text.ParseException;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Set;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.crypto.RSASSAVerifier;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+/** Verification-only parser for compact JWS Agent signing certificates. */
+final class AgentSigningCertificateVerifier {
+
+ private static final int SHA_256_LENGTH = 32;
+ private static final String VERSION = "version";
+ private static final String AGENT_PUBLIC_KEY_DIGEST = "agentPublicKeyDigest";
+ private static final String LICENSEE_NUMBER = "licenseeNumber";
+ private static final String NOT_BEFORE = "notBefore";
+ private static final String NOT_AFTER = "notAfter";
+ private static final Set ALLOWED_CLAIMS = Set.of(VERSION, AGENT_PUBLIC_KEY_DIGEST, LICENSEE_NUMBER,
+ NOT_BEFORE, NOT_AFTER);
+ private static final Set ALLOWED_HEADER_PARAMETERS = Set.of("alg");
+
+ private AgentSigningCertificateVerifier() {
+ }
+
+ static AgentSigningCertificate verify(final String compactJws, final PublicKey publicKey) {
+ if (!(publicKey instanceof RSAPublicKey rsaPublicKey)) {
+ throw new IllegalArgumentException("Agent signing certificate requires an RSA public key.");
+ }
+
+ final SignedJWT certificate = parse(compactJws);
+ try {
+ if (!certificate.verify(new RSASSAVerifier(rsaPublicKey))) {
+ throw new IllegalArgumentException("Agent signing certificate signature is invalid.");
+ }
+ return readClaims(certificate.getJWTClaimsSet());
+ } catch (final JOSEException | ParseException e) {
+ throw new IllegalArgumentException("Agent signing certificate is invalid.", e);
+ }
+ }
+
+ private static SignedJWT parse(final String compactJws) {
+ requireText(compactJws, "Agent signing certificate");
+ try {
+ final SignedJWT certificate = SignedJWT.parse(compactJws);
+
+ if (!JWSAlgorithm.RS256.equals(certificate.getHeader().getAlgorithm())) {
+ throw new IllegalArgumentException("Unsupported Agent signing certificate algorithm.");
+ }
+
+ if (!ALLOWED_HEADER_PARAMETERS.equals(certificate.getHeader().getIncludedParams())) {
+ throw new IllegalArgumentException("Agent signing certificate contains unexpected header parameters.");
+ }
+
+ return certificate;
+ } catch (final ParseException e) {
+ throw new IllegalArgumentException("Agent signing certificate is malformed.", e);
+ }
+ }
+
+ private static AgentSigningCertificate readClaims(final JWTClaimsSet claims) throws ParseException {
+ if (!ALLOWED_CLAIMS.equals(claims.getClaims().keySet())) {
+ throw new IllegalArgumentException("Agent signing certificate contains unexpected claims.");
+ }
+
+ // Keep the signed format marker available for future evolution without introducing
+ // version-specific behavior while only one certificate format exists.
+ requireNumber(claims, VERSION);
+
+ final Instant notBefore = Instant.ofEpochSecond(requireNumber(claims, NOT_BEFORE).longValue());
+ final Instant notAfter = Instant.ofEpochSecond(requireNumber(claims, NOT_AFTER).longValue());
+ if (!notAfter.isAfter(notBefore)) {
+ throw new IllegalArgumentException("Agent signing certificate validity interval is invalid.");
+ }
+
+ return new AgentSigningCertificate(requireDigest(requireString(claims, AGENT_PUBLIC_KEY_DIGEST)),
+ requireString(claims, LICENSEE_NUMBER), notBefore, notAfter);
+ }
+
+ private static Number requireNumber(final JWTClaimsSet claims, final String name) throws ParseException {
+ final Object value = claims.getClaim(name);
+ if (!(value instanceof Number number)) {
+ throw new ParseException("Claim '" + name + "' is not a number.", 0);
+ }
+ return number;
+ }
+
+ private static String requireString(final JWTClaimsSet claims, final String name) throws ParseException {
+ return requireText(claims.getStringClaim(name), "Claim '" + name + "'");
+ }
+
+ private static String requireDigest(final String digest) {
+ requireText(digest, "Agent public key digest");
+ final byte[] decoded;
+ try {
+ decoded = Base64.getUrlDecoder().decode(digest);
+ } catch (final IllegalArgumentException e) {
+ throw new IllegalArgumentException("Agent public key digest is not valid Base64URL.", e);
+ }
+
+ if (decoded.length != SHA_256_LENGTH
+ || !digest.equals(Base64.getUrlEncoder().withoutPadding().encodeToString(decoded))) {
+ throw new IllegalArgumentException("Agent public key digest is not a canonical SHA-256 digest.");
+ }
+
+ return digest;
+ }
+
+ private static String requireText(final String value, final String name) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(name + " is missing.");
+ }
+
+ return value;
+ }
+}
diff --git a/NetLicensingClient/src/main/resources/schema/netlicensing-context-2.1.xsd b/NetLicensingClient/src/main/resources/schema/netlicensing-context-2.1.xsd
index 4cb53293..e77aa10e 100644
--- a/NetLicensingClient/src/main/resources/schema/netlicensing-context-2.1.xsd
+++ b/NetLicensingClient/src/main/resources/schema/netlicensing-context-2.1.xsd
@@ -20,6 +20,17 @@
XML Signature element.
+
+
+ Security metadata for a response signed by an offline Agent.
+
+
+
+
+
+
+
+
@@ -173,12 +184,12 @@
- Base identifier type. Can be any string 1-32 characters length.
+ Base identifier type. Can be any string 1-255 characters length.
-
+
diff --git a/NetLicensingClient/src/test/java/com/labs64/netlicensing/service/LicenseeServiceTest.java b/NetLicensingClient/src/test/java/com/labs64/netlicensing/service/LicenseeServiceTest.java
index 3b3e7a90..8d0d4551 100644
--- a/NetLicensingClient/src/test/java/com/labs64/netlicensing/service/LicenseeServiceTest.java
+++ b/NetLicensingClient/src/test/java/com/labs64/netlicensing/service/LicenseeServiceTest.java
@@ -156,6 +156,7 @@ public void testValidate() throws Exception {
validationParameters.setLicenseeName("Test Licensee");
validationParameters.setProductNumber(productNumber);
validationParameters.setLicenseeProperty("customProperty", "Licensee Custom Property");
+ validationParameters.setNonce("validation-request-001");
final ValidationResult result = LicenseeService.validate(context, licenseeNumber, validationParameters);
assertNotNull(result);
@@ -266,7 +267,8 @@ public Response validateLicensee(@PathParam("licenseeNumber") final String licen
@FormParam("productNumber") final String productNumber,
@FormParam("licenseeName") final String licenseeName,
@FormParam("customProperty") final String licenseeCustomProperty,
- @FormParam("forOfflineUse") final String forOfflineUse) {
+ @FormParam("forOfflineUse") final String forOfflineUse,
+ @FormParam("nonce") final String nonce) {
if (!productNumber.equals(productNumber)) {
return unexpectedValueErrorResponse("productNumber");
@@ -286,6 +288,9 @@ public Response validateLicensee(@PathParam("licenseeNumber") final String licen
try {
final Netlicensing netlicensing = JAXBUtils.readObject(TEST_CASE_BASE
+ validationFile, Netlicensing.class);
+ if (nonce != null) {
+ netlicensing.setId(nonce);
+ }
return Response.ok(netlicensing).build();
} catch (JAXBException e) {
return Response.serverError().entity("Exception in mocked server: " + e.getMessage()).build();
diff --git a/NetLicensingClient/src/test/java/com/labs64/netlicensing/util/AgentResponseSignatureVerifierTest.java b/NetLicensingClient/src/test/java/com/labs64/netlicensing/util/AgentResponseSignatureVerifierTest.java
new file mode 100644
index 00000000..b1dd4a22
--- /dev/null
+++ b/NetLicensingClient/src/test/java/com/labs64/netlicensing/util/AgentResponseSignatureVerifierTest.java
@@ -0,0 +1,173 @@
+package com.labs64.netlicensing.util;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.StringReader;
+import java.io.StringWriter;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Base64;
+
+import javax.xml.crypto.dsig.CanonicalizationMethod;
+import javax.xml.crypto.dsig.DigestMethod;
+import javax.xml.crypto.dsig.Reference;
+import javax.xml.crypto.dsig.SignatureMethod;
+import javax.xml.crypto.dsig.SignedInfo;
+import javax.xml.crypto.dsig.Transform;
+import javax.xml.crypto.dsig.XMLSignature;
+import javax.xml.crypto.dsig.XMLSignatureFactory;
+import javax.xml.crypto.dsig.dom.DOMSignContext;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.transform.OutputKeys;
+import javax.xml.transform.TransformerFactory;
+import javax.xml.transform.dom.DOMSource;
+import javax.xml.transform.stream.StreamResult;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.w3c.dom.Document;
+import org.xml.sax.InputSource;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.JWSHeader;
+import com.nimbusds.jose.crypto.RSASSASigner;
+import com.nimbusds.jwt.JWTClaimsSet;
+import com.nimbusds.jwt.SignedJWT;
+
+import com.labs64.netlicensing.exception.BadSignatureException;
+
+public class AgentResponseSignatureVerifierTest {
+
+ private static final String NONCE = "00112233445566778899aabbccddeeff";
+ private static final String LICENSEE_NUMBER = "I-001";
+
+ private KeyPair licenseeKeyPair;
+ private KeyPair agentKeyPair;
+
+ @BeforeEach
+ public void setUp() throws Exception {
+ final KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ licenseeKeyPair = generator.generateKeyPair();
+ agentKeyPair = generator.generateKeyPair();
+ }
+
+ @Test
+ public void verifiesCompleteAgentResponseTrustChain() throws Exception {
+ final String response = createSignedResponse(Instant.now().minusSeconds(1),
+ Instant.now().plus(1, ChronoUnit.DAYS));
+
+ assertDoesNotThrow(() -> AgentResponseSignatureVerifier.verify(response, LICENSEE_NUMBER,
+ licenseeKeyPair.getPublic()));
+ }
+
+ @Test
+ public void acceptsPemEncodedLicenseePublicKey() throws Exception {
+ final String response = createSignedResponse(Instant.now().minusSeconds(1),
+ Instant.now().plus(1, ChronoUnit.DAYS));
+ final String publicKey = "-----BEGIN PUBLIC KEY-----\n"
+ + Base64.getEncoder().encodeToString(licenseeKeyPair.getPublic().getEncoded())
+ + "\n-----END PUBLIC KEY-----";
+
+ assertDoesNotThrow(() -> AgentResponseSignatureVerifier.verify(response, LICENSEE_NUMBER, publicKey));
+ }
+
+ @Test
+ public void rejectsModifiedResponse() throws Exception {
+ final String response = createSignedResponse(Instant.now().minusSeconds(1),
+ Instant.now().plus(1, ChronoUnit.DAYS));
+ final String modifiedResponse = response.replace("valid=\"true\"", "valid=\"false\"");
+
+ assertThrows(BadSignatureException.class, () -> AgentResponseSignatureVerifier.verify(modifiedResponse,
+ LICENSEE_NUMBER, licenseeKeyPair.getPublic()));
+ }
+
+ @Test
+ public void rejectsCertificateOutsideItsValidityPeriod() throws Exception {
+ final String response = createSignedResponse(Instant.now().minus(2, ChronoUnit.DAYS),
+ Instant.now().minus(1, ChronoUnit.DAYS));
+
+ assertThrows(BadSignatureException.class, () -> AgentResponseSignatureVerifier.verify(response,
+ LICENSEE_NUMBER, licenseeKeyPair.getPublic()));
+ }
+
+ @Test
+ public void rejectsCertificateSignedByUnexpectedLicenseeKey() throws Exception {
+ final String response = createSignedResponse(Instant.now().minusSeconds(1),
+ Instant.now().plus(1, ChronoUnit.DAYS));
+ final KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ final KeyPair unexpectedLicenseeKeyPair = generator.generateKeyPair();
+
+ assertThrows(BadSignatureException.class, () -> AgentResponseSignatureVerifier.verify(response,
+ LICENSEE_NUMBER, unexpectedLicenseeKeyPair.getPublic()));
+ }
+
+ @Test
+ public void acceptsNumericCertificateVersionWithoutVersionSpecificHandling() throws Exception {
+ final String response = createSignedResponse(Instant.now().minusSeconds(1),
+ Instant.now().plus(1, ChronoUnit.DAYS), 2);
+
+ assertDoesNotThrow(() -> AgentResponseSignatureVerifier.verify(response, LICENSEE_NUMBER,
+ licenseeKeyPair.getPublic()));
+ }
+
+ @Test
+ public void rejectsUnexpectedLicensee() throws Exception {
+ final String response = createSignedResponse(Instant.now().minusSeconds(1),
+ Instant.now().plus(1, ChronoUnit.DAYS));
+
+ assertThrows(BadSignatureException.class, () -> AgentResponseSignatureVerifier.verify(response, "I-002",
+ licenseeKeyPair.getPublic()));
+ }
+
+ private String createSignedResponse(final Instant notBefore, final Instant notAfter) throws Exception {
+ return createSignedResponse(notBefore, notAfter, 1);
+ }
+
+ private String createSignedResponse(final Instant notBefore, final Instant notAfter, final int version)
+ throws Exception {
+ final String digest = Base64.getUrlEncoder().withoutPadding().encodeToString(
+ java.security.MessageDigest.getInstance("SHA-256").digest(agentKeyPair.getPublic().getEncoded()));
+ final JWTClaimsSet claims = new JWTClaimsSet.Builder().claim("version", version)
+ .claim("agentPublicKeyDigest", digest).claim("licenseeNumber", LICENSEE_NUMBER)
+ .claim("notBefore", notBefore.getEpochSecond()).claim("notAfter", notAfter.getEpochSecond()).build();
+ final SignedJWT certificate = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).build(), claims);
+ certificate.sign(new RSASSASigner(licenseeKeyPair.getPrivate()));
+
+ final String encodedAgentPublicKey = Base64.getEncoder().encodeToString(agentKeyPair.getPublic().getEncoded());
+ final String unsignedResponse = "" + certificate.serialize()
+ + "" + encodedAgentPublicKey
+ + " ";
+
+ final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
+ documentBuilderFactory.setNamespaceAware(true);
+ final Document document = documentBuilderFactory.newDocumentBuilder()
+ .parse(new InputSource(new StringReader(unsignedResponse)));
+ final XMLSignatureFactory signatureFactory = XMLSignatureFactory.getInstance("DOM");
+ final Transform transform = signatureFactory.newTransform(Transform.ENVELOPED,
+ (javax.xml.crypto.dsig.spec.TransformParameterSpec) null);
+ final Reference reference = signatureFactory.newReference("",
+ signatureFactory.newDigestMethod(DigestMethod.SHA256, null), java.util.List.of(transform), null, null);
+ final SignedInfo signedInfo = signatureFactory.newSignedInfo(
+ signatureFactory.newCanonicalizationMethod(CanonicalizationMethod.INCLUSIVE,
+ (javax.xml.crypto.dsig.spec.C14NMethodParameterSpec) null),
+ signatureFactory.newSignatureMethod(SignatureMethod.RSA_SHA256, null), java.util.List.of(reference));
+ final XMLSignature signature = signatureFactory.newXMLSignature(signedInfo, null);
+ final DOMSignContext signContext = new DOMSignContext(agentKeyPair.getPrivate(), document.getDocumentElement(),
+ document.getDocumentElement().getFirstChild());
+ signContext.setDefaultNamespacePrefix("");
+ signature.sign(signContext);
+
+ final StringWriter writer = new StringWriter();
+ final javax.xml.transform.Transformer transformer = TransformerFactory.newInstance().newTransformer();
+ transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "no");
+ transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
+ transformer.transform(new DOMSource(document), new StreamResult(writer));
+ return writer.toString();
+ }
+}
diff --git a/README.md b/README.md
index beffc6b0..71349f9c 100644
--- a/README.md
+++ b/README.md
@@ -36,3 +36,27 @@ OSGi:
```
Require-Bundle: com.labs64.netlicensing.client;bundle-version="X.Y.Z"
```
+
+## Verify signed Agent validation responses
+
+Products using protected offline validation must verify the raw XML response before trusting its validation result.
+The Licensee API key public key can be supplied as a `PublicKey` or as PEM:
+
+```java
+import com.labs64.netlicensing.util.AgentResponseSignatureVerifier;
+
+AgentResponseSignatureVerifier.verify(
+ responseXml,
+ licenseeNumber,
+ licenseeApiKeyPublicKeyPem);
+```
+
+The verifier checks the Licensee scope and validity of the Agent signing certificate, the binding between that
+certificate and the Agent public key, and finally the signature of the complete XML response. It throws
+`BadSignatureException` when any check fails; the Product must reject the response in that case. If the request contains
+a nonce, the Product must additionally compare it with the signed `netlicensing@id` response attribute.
+
+`LicenseeService.validate` performs this selection automatically when `Context.publicKey` is configured: responses
+containing `agentSigning` use the Agent trust chain, while regular signed Core responses retain the existing direct
+signature verification. Call `ValidationParameters.setNonce(...)` when request/response replay protection is required;
+the client sends it and rejects a response whose signed `netlicensing@id` differs.
diff --git a/pom.xml b/pom.xml
index eedfc274..81ed77f5 100644
--- a/pom.xml
+++ b/pom.xml
@@ -87,6 +87,7 @@
4.0.5
4.0.9
6.1.0
+ 10.10
1.86
@@ -458,6 +459,11 @@
ph-xmldsig
${ph-xmldsig.version}
+
+ com.nimbusds
+ nimbus-jose-jwt
+ ${nimbus-jose-jwt.version}
+
org.bouncycastle
bcprov-jdk18on