More capabilities in SdJwtVP API when creating presentations (#44977)

closes #44976

Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
Marek Posolda 2025-12-18 10:58:55 +01:00 committed by GitHub
parent 4b68f6998b
commit 92314bccc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 138 additions and 7 deletions

View File

@ -209,19 +209,32 @@ public class SdJwtVP {
return issuerSignedJWT.getCnfClaim().orElse(null);
}
/**
* Create new Sd-JWT presentation from this Sd-JWT
*
* @param disclosureDigests Disclosure digests (hashes) of the claims to disclose.
* @param discloseAllClaims When the parameter is true, then disclosureDigests parameter is ignored and everything is presented. When false, then only claims specified
* by disclosureDigests are presented
* @param keyBindingClaims Key binding claims. When omitted, created presentation may not contain key-binding
* @param holdSignatureSignerContext Useful for signing the key-binding JWT
* @return String with new Sd-JWT presentation with added key-binding and selected disclosed claims
*/
public String present(List<String> disclosureDigests,
boolean discloseAllClaims,
ObjectNode keyBindingClaims,
SignatureSignerContext holdSignatureSignerContext) {
StringBuilder sb = new StringBuilder();
if (disclosureDigests == null || disclosureDigests.isEmpty()) {
if (discloseAllClaims) {
// disclose everything
sb.append(sdJwtVpString);
} else {
sb.append(issuerSignedJWT.getJws());
sb.append(SDJWT_DELIMITER);
for (String disclosureDigest : disclosureDigests) {
sb.append(disclosures.get(disclosureDigest));
sb.append(SDJWT_DELIMITER);
if (disclosureDigests != null) {
for (String disclosureDigest : disclosureDigests) {
sb.append(disclosures.get(disclosureDigest));
sb.append(SDJWT_DELIMITER);
}
}
}
String unboundPresentation = sb.toString();
@ -238,6 +251,42 @@ public class SdJwtVP {
return sb.toString();
}
/**
* Create new Sd-JWT presentation from this Sd-JWT. It works same like {@link #present(List, boolean, ObjectNode, SignatureSignerContext)} but it allows
* to specify the names of the claims to present (EG. given_name, family_name) instead of specifying disclosureDigests
*
* @param claimsToDisclose Names of the claims to disclose (EG. given_name, family_name)
* @param discloseAllClaims Used in case that claimsToDisclose is empty or null. In case this is true, all the claims from this SdJWT will be disclosed.
* If it is false, then only claims specified by claimsToDisclose parameter would be disclosed
* @param keyBindingClaims Key binding claims. When omitted, created presentation may not contain key-binding
* @param holdSignatureSignerContext Useful for signing the key-binding JWT
* @return String with new Sd-JWT presentation with added key-binding and selected disclosed claims
*/
public String presentWithSpecifiedClaims(List<String> claimsToDisclose,
boolean discloseAllClaims,
ObjectNode keyBindingClaims,
SignatureSignerContext holdSignatureSignerContext) {
if (discloseAllClaims) {
return present(null, true, keyBindingClaims, holdSignatureSignerContext);
} else {
List<String> digests = getClaims().entrySet().stream()
.filter(entry -> {
ArrayNode node = entry.getValue();
if (node.size() >= 2) {
String claimName = node.get(1).asText();
return (claimsToDisclose.contains(claimName));
}
return false;
})
.map(Map.Entry::getKey)
.sorted()
.collect(Collectors.toList());
return present(digests, false, keyBindingClaims, holdSignatureSignerContext);
}
}
/**
* Verifies SD-JWT presentation.
*

View File

@ -17,6 +17,11 @@
package org.keycloak.sdjwt.sdjwtvp;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.keycloak.common.VerificationException;
import org.keycloak.crypto.Algorithm;
@ -30,10 +35,12 @@ import org.keycloak.sdjwt.vp.SdJwtVP;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.Assert;
import org.junit.ClassRule;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@ -150,7 +157,7 @@ public abstract class SdJwtVPTest {
String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s6.2-presented-sdjwtvp.txt");
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
ObjectNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json");
String presentation = sdJwtVP.present(null, keyBindingClaims,
String presentation = sdJwtVP.present(null, true, keyBindingClaims,
TestSettings.getInstance().getHolderSignerContext());
SdJwtVP presenteSdJwtVP = SdJwtVP.of(presentation);
@ -162,6 +169,8 @@ public abstract class SdJwtVPTest {
// Verify with public key from cnf claim
presenteSdJwtVP.getKeyBindingJWT().get()
.verifySignature(TestSettings.verifierContextFrom(presenteSdJwtVP.getCnfClaim(), Algorithm.ES256));
assertExpectedClaims(presenteSdJwtVP, Arrays.asList("address", "given_name", "family_name"));
}
@Test(expected = VerificationException.class)
@ -169,7 +178,7 @@ public abstract class SdJwtVPTest {
String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s6.2-presented-sdjwtvp.txt");
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
ObjectNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json");
String presentation = sdJwtVP.present(null, keyBindingClaims,
String presentation = sdJwtVP.present(null, true, keyBindingClaims,
TestSettings.getInstance().getHolderSignerContext());
SdJwtVP presenteSdJwtVP = SdJwtVP.of(presentation);
@ -188,7 +197,7 @@ public abstract class SdJwtVPTest {
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
ObjectNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json");
// disclose only the given_name
String presentation = sdJwtVP.present(Arrays.asList("jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4"),
String presentation = sdJwtVP.present(Arrays.asList("jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4"), false,
keyBindingClaims, TestSettings.getInstance().getHolderSignerContext());
SdJwtVP presenteSdJwtVP = SdJwtVP.of(presentation);
@ -197,6 +206,79 @@ public abstract class SdJwtVPTest {
// Verify with public key from cnf claim
presenteSdJwtVP.getKeyBindingJWT().get()
.verifySignature(TestSettings.verifierContextFrom(presenteSdJwtVP.getCnfClaim(), Algorithm.ES256));
assertExpectedClaims(presenteSdJwtVP, Collections.singletonList("given_name"));
}
@Test
public void testPresentationWithoutDisclosures() throws VerificationException {
String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s6.2-presented-sdjwtvp.txt");
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
ObjectNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json");
// Presentation without any disclosed claims
String presentation = sdJwtVP.present(Collections.emptyList(), false,
keyBindingClaims, TestSettings.getInstance().getHolderSignerContext());
SdJwtVP presentedSdJwtVP = SdJwtVP.of(presentation);
assertTrue(presentedSdJwtVP.getKeyBindingJWT().isPresent());
// Verify with public key from cnf claim
presentedSdJwtVP.getKeyBindingJWT().get()
.verifySignature(TestSettings.verifierContextFrom(presentedSdJwtVP.getCnfClaim(), Algorithm.ES256));
// Assert no claims disclosed
assertExpectedClaims(presentedSdJwtVP, Collections.emptyList());
}
@Test
public void testPresentationOfSpecifiedClaims() throws VerificationException {
String sdJwtVPString = TestUtils.readFileAsString(getClass(), "sdjwt/s6.2-presented-sdjwtvp.txt");
SdJwtVP sdJwtVP = SdJwtVP.of(sdJwtVPString);
ObjectNode keyBindingClaims = TestUtils.readClaimSet(getClass(), "sdjwt/s6.2-key-binding-claims.json");
// Disclosures of family_name and given_name
String presentation = sdJwtVP.present(Arrays.asList("TGf4oLbgwd5JQaHyKVQZU9UdGE0w5rtDsrZzfUaomLo", "jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4"),
false, null, null);
// Creating presentation with directly specifying claims I want to disclose
String presentation2 = sdJwtVP.presentWithSpecifiedClaims(Arrays.asList("given_name", "family_name"), false,
null, null);
Assert.assertEquals(presentation, presentation2);
// Specifying not-existent claims works as well. Non-existent claim is ignored
String presentation3 = sdJwtVP.presentWithSpecifiedClaims(Arrays.asList("given_name", "family_name", "non-existent"), false,
null, null);
Assert.assertEquals(presentation, presentation3);
// Test with key-binding not present
SdJwtVP presentedSdJwtVP = SdJwtVP.of(presentation);
assertFalse(presentedSdJwtVP.getKeyBindingJWT().isPresent());
// Test with key-binding present
String presentation4 = sdJwtVP.presentWithSpecifiedClaims(Arrays.asList("given_name", "family_name"), false,
keyBindingClaims, TestSettings.getInstance().getHolderSignerContext());
presentedSdJwtVP = SdJwtVP.of(presentation4);
assertTrue(presentedSdJwtVP.getKeyBindingJWT().isPresent());
// Verify with public key from cnf claim
presentedSdJwtVP.getKeyBindingJWT().get()
.verifySignature(TestSettings.verifierContextFrom(presentedSdJwtVP.getCnfClaim(), Algorithm.ES256));
// Assert only given_name and family_name claims disclosed in the new presentation
assertExpectedClaims(presentedSdJwtVP, Arrays.asList("given_name", "family_name"));
}
private void assertExpectedClaims(SdJwtVP presentedSdJwtVP, List<String> expectedClaims) {
Set<String> availableClaims = presentedSdJwtVP.getClaims().values()
.stream()
.filter(arrayNode -> arrayNode.size() == 3) // Filter array claims
.map(arrayNode -> arrayNode.get(1).asText())
.collect(Collectors.toSet());
Assert.assertEquals(availableClaims, new HashSet<>(expectedClaims));
}