Skip to content

Commit 31c7b8c

Browse files
authored
Merge pull request #535 from lissi-id/bugfix/fix-claimpath-handling
bugfix: fix claim path null handling
2 parents dd639d0 + 86ddba0 commit 31c7b8c

9 files changed

Lines changed: 258 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
### [Unreleased]
99

10+
#### Fixed
11+
12+
- Claim paths that select all elements in an array are preserved correctly when credentials or metadata are serialized.
13+
- SD-JWT presentations include the right disclosures for nested and array-shaped claims, not only simple top-level paths.
14+
15+
#### Changed
16+
17+
- SD-JWT holder presentation APIs now take structured claim paths instead of raw path strings.
18+
1019
### [3.0.1] - 2026.02.27
1120

1221
- Fix Database concurrency issues

src/WalletFramework.Core/ClaimPaths/ClaimPath.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,4 +87,13 @@ public static JsonPath ToJsonPath(this ClaimPath claimPath)
8787

8888
return JsonPath.ValidJsonPath(jsonPath.ToString()).UnwrapOrThrow();
8989
}
90+
91+
public static Validation<ClaimPathSelection> ProcessWith(this ClaimPath path, JObject jObject) =>
92+
path.GetPathComponents().Aggregate(
93+
ClaimPathSelection.Create([(JToken)jObject]),
94+
(validation, component) => validation.OnSuccess(selection =>
95+
component.Match(
96+
s => ClaimPathSelectionFun.SelectObjectKey(selection, s),
97+
i => ClaimPathSelectionFun.SelectArrayIndex(selection, i),
98+
_ => ClaimPathSelectionFun.SelectAllArrayElements(selection))));
9099
}

src/WalletFramework.Oid4Vp/ClaimPaths/ClaimPathFun.cs

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
using WalletFramework.MdocLib;
77
using WalletFramework.MdocLib.Elements;
88
using WalletFramework.SdJwtLib.Models;
9-
using static WalletFramework.Core.ClaimPaths.ClaimPathSelectionFun;
109

1110
namespace WalletFramework.Oid4Vp.ClaimPaths;
1211

@@ -24,26 +23,12 @@ public static Validation<ClaimPathSelection> ProcessWith(this ClaimPath path, st
2423
return new InvalidJsonError(json, e);
2524
}
2625

27-
var components = path.GetPathComponents();
28-
return components.Aggregate(
29-
ClaimPathSelection.Create([jObject]),
30-
(validation, component) => validation.OnSuccess(selection =>
31-
{
32-
return component.Match(
33-
s => SelectObjectKey(selection, s),
34-
i => SelectArrayIndex(selection, i),
35-
_ => SelectAllArrayElements(selection)
36-
);
37-
})
38-
);
39-
}
40-
41-
public static Validation<ClaimPathSelection> ProcessWith(this ClaimPath path, SdJwtDoc sdJwtDoc)
42-
{
43-
var jsonStr = sdJwtDoc.UnsecuredPayload.ToString();
44-
return path.ProcessWith(jsonStr);
26+
return path.ProcessWith(jObject);
4527
}
4628

29+
public static Validation<ClaimPathSelection> ProcessWith(this ClaimPath path, SdJwtDoc sdJwtDoc) =>
30+
path.ProcessWith(sdJwtDoc.UnsecuredPayload);
31+
4732
public static Validation<ClaimPathSelection> ProcessWith(this ClaimPath path, Mdoc mdoc)
4833
{
4934
var components = path.GetPathComponents();

src/WalletFramework.Oid4Vp/Services/PresentationService.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using LanguageExt;
22
using Microsoft.IdentityModel.Tokens;
3+
using WalletFramework.Core.ClaimPaths;
4+
using WalletFramework.Core.Credentials;
35
using WalletFramework.Core.Credentials.Abstractions;
46
using WalletFramework.Core.Functional;
57
using WalletFramework.Credentials;
@@ -94,10 +96,14 @@ public PresentationService(
9496
{
9597
case SdJwtCredential sdJwt:
9698
format = CredentialFormat.ValidCredentialFormat(sdJwt.Format).UnwrapOrThrow();
99+
100+
var sdJwtPaths = credential.ClaimsToDisclose.Match(
101+
queries => queries.Select(q => q.Path).ToArray(),
102+
() => Array.Empty<ClaimPath>());
97103

98104
presentation = await _sdJwtVcHolderService.CreatePresentation(
99105
sdJwt,
100-
[.. claims],
106+
sdJwtPaths,
101107
txDataHashesAsHexOption,
102108
txDataHashesAlgOption,
103109
audience,

src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/ISdJwtVcHolderService.cs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using LanguageExt;
2+
using WalletFramework.Core.ClaimPaths;
23

34
namespace WalletFramework.SdJwtVc.Services.SdJwtVcHolderService;
45

@@ -11,10 +12,7 @@ public interface ISdJwtVcHolderService
1112
/// Creates a SD-JWT in presentation format where the provided claims are disclosed.
1213
/// The key binding is optional and can be activated by providing an audience and a nonce.
1314
/// </summary>
14-
/// <remarks>
15-
/// The SD-JWT is created using the provided SD-JWT credential and the provided claims are disclosed
16-
/// </remarks>
17-
/// <param name="disclosedClaimPaths">The claims to disclose</param>
15+
/// <param name="disclosedClaimPaths">The claim paths to disclose</param>
1816
/// <param name="sdJwt">The SD-JWT credential</param>
1917
/// <param name="transactionDataHashes">The transaction data hashes</param>
2018
/// <param name="transactionDataHashesAlg">The transaction data hashes alg</param>
@@ -23,7 +21,7 @@ public interface ISdJwtVcHolderService
2321
/// <returns>The SD-JWT in presentation format</returns>
2422
Task<string> CreatePresentation(
2523
SdJwtCredential sdJwt,
26-
string[] disclosedClaimPaths,
24+
ClaimPath[] disclosedClaimPaths,
2725
Option<IEnumerable<string>> transactionDataHashes,
2826
Option<string> transactionDataHashesAlg,
2927
string? audience = null,

src/WalletFramework.SdJwtVc/Services/SdJwtVcHolderService/SdJwtVcHolderService.cs

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
using LanguageExt;
2+
using Newtonsoft.Json.Linq;
3+
using WalletFramework.Core.ClaimPaths;
24
using WalletFramework.Core.Functional;
35
using WalletFramework.SdJwtLib.Models;
46
using WalletFramework.SdJwtLib.Roles;
7+
using PathSet = System.Collections.Generic.HashSet<string>;
58

69
namespace WalletFramework.SdJwtVc.Services.SdJwtVcHolderService;
710

@@ -11,24 +14,23 @@ public class SdJwtVcHolderService(IHolder holder, ISdJwtSigner signer) : ISdJwtV
1114
/// <inheritdoc />
1215
public async Task<string> CreatePresentation(
1316
SdJwtCredential credential,
14-
string[] disclosedClaimPaths,
17+
ClaimPath[] disclosedClaimPaths,
1518
Option<IEnumerable<string>> transactionDataHashes,
1619
Option<string> transactionDataHashesAlg,
1720
string? audience = null,
1821
string? nonce = null)
1922
{
2023
var sdJwtDoc = credential.ToSdJwtDoc();
21-
var disclosures = new List<Disclosure>();
22-
foreach (var disclosure in sdJwtDoc.Disclosures)
23-
{
24-
if (disclosedClaimPaths.Any(disclosedClaim => disclosedClaim.StartsWith(disclosure.Path ?? string.Empty)))
25-
{
26-
disclosures.Add(disclosure);
27-
}
28-
}
24+
25+
var requiredPaths = CollectRequiredDisclosurePaths(sdJwtDoc.UnsecuredPayload, disclosedClaimPaths);
26+
27+
var disclosures = sdJwtDoc
28+
.Disclosures
29+
.Where(disclosure => !string.IsNullOrEmpty(disclosure.Path) && requiredPaths.Contains(disclosure.Path!))
30+
.ToArray();
2931

3032
var presentationFormat =
31-
holder.CreatePresentationFormat(credential.EncodedIssuerSignedJwt, disclosures.ToArray());
33+
holder.CreatePresentationFormat(credential.EncodedIssuerSignedJwt, disclosures);
3234

3335
if (credential.KeyId.IsSome
3436
&& !string.IsNullOrEmpty(nonce)
@@ -49,4 +51,36 @@ public async Task<string> CreatePresentation(
4951

5052
return presentationFormat.Value;
5153
}
54+
55+
private static PathSet CollectRequiredDisclosurePaths(
56+
JObject payload,
57+
IEnumerable<ClaimPath> paths)
58+
{
59+
var result = new PathSet();
60+
61+
foreach (var path in paths)
62+
{
63+
path
64+
.ProcessWith(payload)
65+
.OnSuccess(selection =>
66+
{
67+
foreach (var token in selection.GetValues())
68+
foreach (var ancestorPath in AncestorPaths(token))
69+
result.Add(ancestorPath);
70+
71+
return Unit.Default;
72+
});
73+
}
74+
75+
return result;
76+
}
77+
78+
private static IEnumerable<string> AncestorPaths(JToken token)
79+
{
80+
for (JToken? current = token; current is not null; current = current.Parent)
81+
{
82+
if (!string.IsNullOrEmpty(current.Path))
83+
yield return current.Path;
84+
}
85+
}
5286
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using FluentAssertions;
2+
using Newtonsoft.Json.Linq;
3+
using WalletFramework.Core.ClaimPaths;
4+
using WalletFramework.Core.Functional;
5+
using WalletFramework.Core.Json;
6+
using WalletFramework.Oid4Vci.CredConfiguration.Models;
7+
using Xunit;
8+
9+
namespace WalletFramework.Oid4Vci.Tests.CredConfiguration;
10+
11+
public class ClaimMetadataTests
12+
{
13+
[Fact]
14+
public void Claim_metadata_preserves_array_wildcard_paths()
15+
{
16+
var path = new JArray("credentialContent", "degreePrograms", JValue.CreateNull(), "name");
17+
var json = new JObject { ["path"] = path };
18+
19+
var result = ClaimMetadata.ValidClaimMetadata(
20+
json,
21+
jt => jt.ToJArray().OnSuccess(ClaimPath.FromJArray));
22+
23+
result.IsSuccess.Should().BeTrue();
24+
25+
var roundTripped = result.UnwrapOrThrow().EncodeToJson()["path"]!;
26+
JToken.DeepEquals(roundTripped, path).Should().BeTrue();
27+
}
28+
}

test/WalletFramework.Oid4Vp.Tests/ClaimPaths/ClaimPathJsonCredentialTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using FluentAssertions;
2+
using Newtonsoft.Json.Linq;
23
using WalletFramework.Core.ClaimPaths;
34
using WalletFramework.Core.ClaimPaths.Errors;
45
using WalletFramework.Core.Functional;
@@ -111,4 +112,58 @@ public void Processing_Unknown_Component_Results_In_Error()
111112
errors => errors.Should().ContainSingle(e => e is UnknownComponentError)
112113
);
113114
}
115+
116+
[Fact]
117+
public void ClaimPathSelection_Selects_All_Array_Elements_With_Null()
118+
{
119+
var credential = JObject.Parse(
120+
"""
121+
{
122+
"degrees": [
123+
{ "type": "Bachelor of Science", "university": "U1" },
124+
{ "type": "Master of Science", "university": "U2" }
125+
]
126+
}
127+
""");
128+
129+
var path = ClaimPath.FromJArray(new JArray("degrees", JValue.CreateNull(), "type")).UnwrapOrThrow();
130+
131+
path.ProcessWith(credential).Match(
132+
selection => selection
133+
.GetValues()
134+
.Select(t => t.ToString())
135+
.Should()
136+
.BeEquivalentTo(new[] { "Bachelor of Science", "Master of Science" }),
137+
_ => Assert.Fail("ClaimPathSelection validation failed"));
138+
}
139+
140+
[Fact]
141+
public void ClaimPathSelection_Selects_By_Integer_Index()
142+
{
143+
var credential = JObject.Parse(
144+
"""
145+
{ "nationalities": ["British", "Betelgeusian"] }
146+
""");
147+
148+
var path = ClaimPath.FromJArray(new JArray("nationalities", 1)).UnwrapOrThrow();
149+
150+
path.ProcessWith(credential).Match(
151+
selection => selection
152+
.GetValues()
153+
.Select(t => t.ToString())
154+
.Should()
155+
.BeEquivalentTo(new[] { "Betelgeusian" }),
156+
_ => Assert.Fail("ClaimPathSelection validation failed"));
157+
}
158+
159+
[Fact]
160+
public void ClaimPathSelection_Errors_When_Component_Targets_NonArray()
161+
{
162+
var credential = JObject.Parse("""{ "name": "Arthur" }""");
163+
var path = ClaimPath.FromJArray(new JArray("name", JValue.CreateNull())).UnwrapOrThrow();
164+
165+
path.ProcessWith(credential).Match(
166+
_ => Assert.Fail("Expected error, got selection"),
167+
errors => errors.Should().ContainSingle(e => e is SelectedElementIsNotAnArrayError));
168+
}
114169
}

0 commit comments

Comments
 (0)