From 2fc5d022fb8cb9cfd82c9271429e7a4cacf19234 Mon Sep 17 00:00:00 2001 From: Slava Rudkovskiy Date: Mon, 26 Oct 2026 12:17:55 +0000 Subject: [PATCH 1/3] Model signed Agent validation responses --- .../resources/schema/netlicensing-context-2.1.xsd | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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. - + From 962b5f21f4c0f43d0955ae1ce0dd531167dbed9c Mon Sep 17 00:00:00 2001 From: Slava Rudkovskiy Date: Mon, 26 Oct 2026 12:18:09 +0000 Subject: [PATCH 2/3] Verify Agent validation response signatures --- NetLicensingClient/pom.xml | 4 + .../exception/BadSignatureException.java | 12 + .../util/AgentResponseSignatureVerifier.java | 251 ++++++++++++++++++ .../util/AgentSigningCertificate.java | 12 + .../util/AgentSigningCertificateVerifier.java | 122 +++++++++ .../AgentResponseSignatureVerifierTest.java | 173 ++++++++++++ pom.xml | 6 + 7 files changed, 580 insertions(+) create mode 100644 NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentResponseSignatureVerifier.java create mode 100644 NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentSigningCertificate.java create mode 100644 NetLicensingClient/src/main/java/com/labs64/netlicensing/util/AgentSigningCertificateVerifier.java create mode 100644 NetLicensingClient/src/test/java/com/labs64/netlicensing/util/AgentResponseSignatureVerifierTest.java 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/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/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/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/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 From 8f91a1c112256b8186b25756148a021b50659a63 Mon Sep 17 00:00:00 2001 From: Slava Rudkovskiy Date: Mon, 26 Oct 2026 12:18:44 +0000 Subject: [PATCH 3/3] Apply Agent signature verification during validation --- .../labs64/netlicensing/domain/Constants.java | 1 + .../domain/vo/ValidationParameters.java | 15 +++++++ .../provider/RestProviderImpl.java | 1 + .../netlicensing/provider/RestResponse.java | 10 +++++ .../service/NetLicensingService.java | 45 ++++++++++++++----- .../service/ValidationService.java | 7 ++- .../service/LicenseeServiceTest.java | 7 ++- README.md | 24 ++++++++++ 8 files changed, 95 insertions(+), 15 deletions(-) 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/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/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/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.