1+ using System . Security . Cryptography ;
2+ using System . Text ;
3+ using System . Text . Json ;
4+ using Jose ;
5+
6+ namespace Pandatech . Crypto . Helpers ;
7+
8+ public static class JoseJwe
9+ {
10+ public static ( string PublicJwk , string PrivateJwk , string Kid ) IssueKeys ( int bits = 2048 )
11+ {
12+ if ( bits < 2048 )
13+ {
14+ throw new ArgumentOutOfRangeException ( nameof ( bits ) , "RSA key must be >= 2048 bits." ) ;
15+ }
16+
17+ using var rsa = RSA . Create ( bits ) ;
18+ var pubJwk = ExportPublicJwk ( rsa ) ;
19+ var prvJwk = ExportPrivateJwk ( rsa ) ;
20+ var kid = Thumbprint ( pubJwk ) ;
21+ return ( pubJwk , prvJwk , kid ) ;
22+ }
23+
24+ public static string Encrypt ( string publicJwk , byte [ ] payload , string kid )
25+ {
26+ // Validate kid matches public key
27+ var computed = Thumbprint ( publicJwk ) ;
28+ if ( ! string . Equals ( computed , kid , StringComparison . Ordinal ) )
29+ {
30+ throw new ArgumentException ( "kid does not match publicJwk (RFC7638)." , nameof ( kid ) ) ;
31+ }
32+
33+ using var rsa = ImportPublic ( publicJwk ) ;
34+
35+ // JWE: RSA-OAEP-256 + A256GCM; compact serialization; header includes kid
36+ return JWT . EncodeBytes (
37+ payload ,
38+ rsa ,
39+ JweAlgorithm . RSA_OAEP_256 ,
40+ JweEncryption . A256GCM ,
41+ extraHeaders : new Dictionary < string , object >
42+ {
43+ [ "kid" ] = kid
44+ }
45+ ) ;
46+ }
47+
48+ public static bool TryDecrypt ( string privateJwk , string jwe , out byte [ ] payload )
49+ {
50+ try
51+ {
52+ using var rsa = ImportPrivate ( privateJwk ) ;
53+ payload = JWT . DecodeBytes ( jwe , rsa , JweAlgorithm . RSA_OAEP_256 , JweEncryption . A256GCM ) ;
54+ return true ;
55+ }
56+ catch
57+ {
58+ payload = [ ] ;
59+ return false ;
60+ }
61+ }
62+
63+ public static string ComputeKid ( string publicJwk ) => Thumbprint ( publicJwk ) ;
64+
65+ private static RSA ImportPublic ( string jwkJson )
66+ {
67+ using var doc = JsonDocument . Parse ( jwkJson ) ;
68+ var r = doc . RootElement ;
69+ if ( r . GetProperty ( "kty" )
70+ . GetString ( ) != "RSA" ) throw new ArgumentException ( "kty must be RSA." ) ;
71+ var n = Base64Url . Decode ( r . GetProperty ( "n" )
72+ . GetString ( ) ! ) ;
73+
74+ if ( n . Length * 8 < 2048 )
75+ {
76+ throw new CryptographicException ( "RSA public key must be >= 2048 bits." ) ;
77+ }
78+
79+ var e = Base64Url . Decode ( r . GetProperty ( "e" )
80+ . GetString ( ) ! ) ;
81+ var p = new RSAParameters
82+ {
83+ Modulus = n ,
84+ Exponent = e
85+ } ;
86+ var rsa = RSA . Create ( ) ;
87+ rsa . ImportParameters ( p ) ;
88+ return rsa ;
89+ }
90+
91+ private static RSA ImportPrivate ( string jwkJson )
92+ {
93+ using var doc = JsonDocument . Parse ( jwkJson ) ;
94+ var r = doc . RootElement ;
95+ if ( r . GetProperty ( "kty" )
96+ . GetString ( ) != "RSA" )
97+ {
98+ throw new ArgumentException ( "kty must be RSA." ) ;
99+ }
100+
101+ var n = Base64Url . Decode ( r . GetProperty ( "n" )
102+ . GetString ( ) ! ) ;
103+ if ( n . Length * 8 < 2048 )
104+ {
105+ throw new CryptographicException ( "RSA private key must be >= 2048 bits." ) ;
106+ }
107+
108+ var pars = new RSAParameters
109+ {
110+ Modulus = Base64Url . Decode ( r . GetProperty ( "n" )
111+ . GetString ( ) ! ) ,
112+
113+ Exponent = Base64Url . Decode ( r . GetProperty ( "e" )
114+ . GetString ( ) ! ) ,
115+ D = Base64Url . Decode ( r . GetProperty ( "d" )
116+ . GetString ( ) ! )
117+ } ;
118+
119+ // optional CRT params if present
120+ Try ( r , "p" , out pars . P ) ;
121+ Try ( r , "q" , out pars . Q ) ;
122+ Try ( r , "dp" , out pars . DP ) ;
123+ Try ( r , "dq" , out pars . DQ ) ;
124+ Try ( r , "qi" , out pars . InverseQ ) ;
125+
126+ var rsa = RSA . Create ( ) ;
127+ rsa . ImportParameters ( pars ) ;
128+ return rsa ;
129+
130+ static void Try ( JsonElement root , string name , out byte [ ] ? val )
131+ {
132+ val = root . TryGetProperty ( name , out var v ) ? Base64Url . Decode ( v . GetString ( ) ! ) : null ;
133+ }
134+ }
135+
136+ private static string ExportPublicJwk ( RSA rsa )
137+ {
138+ var p = rsa . ExportParameters ( false ) ;
139+ var o = new
140+ {
141+ kty = "RSA" ,
142+ n = Base64Url . Encode ( p . Modulus ! ) ,
143+ e = Base64Url . Encode ( p . Exponent ! )
144+ } ;
145+ return JsonSerializer . Serialize ( o ) ;
146+ }
147+
148+ private static string ExportPrivateJwk ( RSA rsa )
149+ {
150+ var p = rsa . ExportParameters ( true ) ;
151+ var o = new
152+ {
153+ kty = "RSA" ,
154+ n = Base64Url . Encode ( p . Modulus ! ) ,
155+ e = Base64Url . Encode ( p . Exponent ! ) ,
156+ d = Base64Url . Encode ( p . D ! ) ,
157+ p = p . P is null ? null : Base64Url . Encode ( p . P ) ,
158+ q = p . Q is null ? null : Base64Url . Encode ( p . Q ) ,
159+ dp = p . DP is null ? null : Base64Url . Encode ( p . DP ) ,
160+ dq = p . DQ is null ? null : Base64Url . Encode ( p . DQ ) ,
161+ qi = p . InverseQ is null ? null : Base64Url . Encode ( p . InverseQ )
162+ } ;
163+ var json = JsonSerializer . Serialize ( o ) ;
164+ // remove nulls (compact)
165+ using var doc = JsonDocument . Parse ( json ) ;
166+ using var ms = new MemoryStream ( ) ;
167+ using var w = new Utf8JsonWriter ( ms ) ;
168+ w . WriteStartObject ( ) ;
169+ foreach ( var prop in doc . RootElement
170+ . EnumerateObject ( )
171+ . Where ( prop => prop . Value . ValueKind != JsonValueKind . Null ) )
172+ {
173+ prop . WriteTo ( w ) ;
174+ }
175+
176+ w . WriteEndObject ( ) ;
177+ w . Flush ( ) ;
178+ return Encoding . UTF8 . GetString ( ms . ToArray ( ) ) ;
179+ }
180+
181+ // RFC 7638 thumbprint over {"e","kty","n"} with lexicographic keys
182+ private static string Thumbprint ( string publicRsaJwk )
183+ {
184+ using var doc = JsonDocument . Parse ( publicRsaJwk ) ;
185+ var r = doc . RootElement ;
186+ var canonical =
187+ $$ """ {"e":"{{ r . GetProperty ( "e" ) . GetString ( ) }} " ,"kty":"RSA","n":"{{ r . GetProperty ( "n" ) . GetString ( ) }} "}"""
188+ . Replace ( " " , "" ) ;
189+ var hash = SHA256 . HashData ( Encoding . ASCII . GetBytes ( canonical ) ) ;
190+ return Base64Url . Encode ( hash ) ;
191+ }
192+ }
0 commit comments