
001package org.hl7.fhir.r5.elementmodel; 002 003import java.io.ByteArrayInputStream; 004import java.io.ByteArrayOutputStream; 005import java.io.IOException; 006import java.io.InputStream; 007import java.io.OutputStream; 008import java.net.MalformedURLException; 009import java.net.URL; 010import java.nio.charset.StandardCharsets; 011import java.text.ParseException; 012import java.util.ArrayList; 013import java.util.Arrays; 014import java.util.Base64; 015import java.util.HashMap; 016import java.util.HashSet; 017import java.util.List; 018import java.util.Map; 019import java.util.Map.Entry; 020import java.util.zip.DataFormatException; 021import java.util.zip.Inflater; 022 023import org.hl7.fhir.exceptions.DefinitionException; 024import org.hl7.fhir.exceptions.FHIRException; 025import org.hl7.fhir.exceptions.FHIRFormatError; 026import org.hl7.fhir.r5.context.IWorkerContext; 027import org.hl7.fhir.r5.elementmodel.SHCParser.SHCSignedJWT; 028import org.hl7.fhir.r5.formats.IParser.OutputStyle; 029import org.hl7.fhir.utilities.FileUtilities; 030import org.hl7.fhir.utilities.MarkedToMoveToAdjunctPackage; 031import org.hl7.fhir.utilities.Utilities; 032import org.hl7.fhir.utilities.VersionUtilities; 033import org.hl7.fhir.utilities.http.HTTPRequest; 034import org.hl7.fhir.utilities.http.HTTPResult; 035import org.hl7.fhir.utilities.http.ManagedWebAccess; 036import org.hl7.fhir.utilities.json.JsonException; 037import org.hl7.fhir.utilities.json.model.JsonArray; 038import org.hl7.fhir.utilities.json.model.JsonElement; 039import org.hl7.fhir.utilities.json.model.JsonElementType; 040import org.hl7.fhir.utilities.json.model.JsonObject; 041import org.hl7.fhir.utilities.json.model.JsonPrimitive; 042import org.hl7.fhir.utilities.json.model.JsonProperty; 043import org.hl7.fhir.utilities.validation.ValidationMessage; 044import org.hl7.fhir.utilities.validation.ValidationMessage.IssueSeverity; 045import org.hl7.fhir.utilities.validation.ValidationMessage.IssueType; 046 047import com.nimbusds.jose.*; 048import com.nimbusds.jose.crypto.ECDSAVerifier; 049import com.nimbusds.jose.jwk.ECKey; 050import com.nimbusds.jose.jwk.JWK; 051import com.nimbusds.jose.jwk.JWKSet; 052import com.nimbusds.jose.jwk.source.*; 053import com.nimbusds.jose.proc.BadJOSEException; 054import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; 055import com.nimbusds.jose.proc.JWSKeySelector; 056import com.nimbusds.jose.proc.JWSVerificationKeySelector; 057import com.nimbusds.jose.proc.SecurityContext; 058import com.nimbusds.jose.util.JSONObjectUtils; 059import com.nimbusds.jwt.*; 060import com.nimbusds.jwt.proc.*; 061/** 062 * this class is actually a smart health cards validator. 063 * It's going to parse the JWT and assume that it contains 064 * a smart health card, which has a nested bundle in it, and 065 * then validate the bundle. 066 * 067 * See https://spec.smarthealth.cards/#health-cards-are-encoded-as-compact-serialization-json-web-signatures-jws 068 * 069 * This parser dose the JWT work, and then passes the JsonObject through to the underlying JsonParser 070 * 071 * Error locations are in the decoded payload 072 * 073 * @author grahame 074 * 075 */ 076@MarkedToMoveToAdjunctPackage 077public class SHCParser extends ParserBase { 078 079 private JsonParser jsonParser; 080 private List<String> types = new ArrayList<>(); 081 082 public SHCParser(IWorkerContext context) { 083 super(context); 084 jsonParser = new JsonParser(context); 085 } 086 087 public List<ValidatedFragment> parse(InputStream inStream) throws IOException, FHIRFormatError, DefinitionException, FHIRException { 088 byte[] content = FileUtilities.streamToBytes(inStream); 089 ByteArrayInputStream stream = new ByteArrayInputStream(content); 090 List<ValidatedFragment> res = new ArrayList<>(); 091 ValidatedFragment shc = new ValidatedFragment("shc", "txt", content, false); 092 res.add(shc); 093 094 String src = FileUtilities.streamToString(stream).trim(); 095 List<String> list = new ArrayList<>(); 096 String pfx = null; 097 if (src.startsWith("{")) { 098 JsonObject json = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(src); 099 if (checkProperty(shc.getErrors(), json, "$", "verifiableCredential", true, "Array")) { 100 pfx = "verifiableCredential"; 101 JsonArray arr = json.getJsonArray("verifiableCredential"); 102 int i = 0; 103 for (JsonElement e : arr) { 104 if (!(e instanceof JsonPrimitive)) { 105 logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, line(e), col(e), "$.verifiableCredential["+i+"]", IssueType.STRUCTURE, "Wrong Property verifiableCredential in JSON Payload. Expected : String but found "+e.type().toName(), IssueSeverity.ERROR); 106 } else { 107 list.add(e.asString()); 108 } 109 i++; 110 } 111 } else { 112 return res; 113 } 114 } else { 115 list.add(src); 116 } 117 int c = 0; 118 for (String ssrc : list) { 119 String prefix = pfx == null ? "" : pfx+"["+Integer.toString(c)+"]."; 120 c++; 121 JWT jwt = null; 122 try { 123 jwt = decodeJWT(shc.getErrors(), ssrc); 124 } catch (Exception e) { 125 logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, 1, 1, prefix+"JWT", IssueType.INVALID, "Unable to decode JWT token", IssueSeverity.ERROR); 126 return res; 127 } 128 129 ValidatedFragment bnd = new ValidatedFragment("payload", "json", jwt.payloadSrc, true); 130 res.add(bnd); 131 checkNamedProperties(shc.getErrors(), jwt.getPayload(), prefix+"payload", "iss", "nbf", "vc"); 132 checkProperty(shc.getErrors(), jwt.getPayload(), prefix+"payload", "iss", true, "String"); 133 checkProperty(shc.getErrors(), jwt.getPayload(), prefix+"payload", "nbf", true, "Number"); 134 JsonObject vc = jwt.getPayload().getJsonObject("vc"); 135 if (vc == null) { 136 logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, 1, 1, "JWT", IssueType.STRUCTURE, "Unable to find property 'vc' in the payload", IssueSeverity.ERROR); 137 return res; 138 } 139 String path = prefix+"payload.vc"; 140 checkNamedProperties(shc.getErrors(), vc, path, "type", "credentialSubject"); 141 if (!checkProperty(shc.getErrors(), vc, path, "type", true, "Array")) { 142 return res; 143 } 144 JsonArray type = vc.getJsonArray("type"); 145 int i = 0; 146 for (JsonElement e : type) { 147 if (e.type() != JsonElementType.STRING) { 148 logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, line(e), col(e), path+".type["+i+"]", IssueType.STRUCTURE, "Wrong Property Type in JSON Payload. Expected : String but found "+e.type().toName(), IssueSeverity.ERROR); 149 } else { 150 types.add(e.asString()); 151 } 152 i++; 153 } 154 if (!types.contains("https://smarthealth.cards#health-card")) { 155 logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, line(vc), col(vc), path, IssueType.STRUCTURE, "Card does not claim to be of type https://smarthealth.cards#health-card, cannot validate", IssueSeverity.ERROR); 156 return res; 157 } 158 if (!checkProperty(shc.getErrors(), vc, path, "credentialSubject", true, "Object")) { 159 return res; 160 } 161 JsonObject cs = vc.getJsonObject("credentialSubject"); 162 path = path+".credentialSubject"; 163 if (!checkProperty(shc.getErrors(), cs, path, "fhirVersion", true, "String")) { 164 return res; 165 } 166 JsonElement fv = cs.get("fhirVersion"); 167 if (!VersionUtilities.versionsCompatible(context.getVersion(), fv.asString())) { 168 logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, line(fv), col(fv), path+".fhirVersion", IssueType.STRUCTURE, "Card claims to be of version "+fv.asString()+", cannot be validated against version "+context.getVersion(), IssueSeverity.ERROR); 169 return res; 170 } 171 if (!checkProperty(shc.getErrors(), cs, path, "fhirBundle", true, "Object")) { 172 return res; 173 } 174 // ok. all checks passed, we can now validate the bundle 175 bnd.setElement(jsonParser.parse(bnd.getErrors(), cs.getJsonObject("fhirBundle"), path)); 176 bnd.setElementPath(path); 177 } 178 return res; 179 } 180 181 182 @Override 183 public String getImpliedProfile() { 184 if (types.contains("https://smarthealth.cards#covid19") && types.contains("https://smarthealth.cards#immunization")) { 185 return "http://hl7.org/fhir/uv/shc-vaccination/StructureDefinition/shc-vaccination-bundle-dm"; 186 } 187 if (types.contains("https://smarthealth.cards#covid19") && types.contains("https://smarthealth.cards#laboratory")) { 188 return "http://hl7.org/fhir/uv/shc-vaccination/StructureDefinition/shc-covid19-laboratory-bundle-dm"; 189 } 190 if (types.contains("https://smarthealth.cards#laboratory")) { 191 return "http://hl7.org/fhir/uv/shc-vaccination/StructureDefinition/shc-infectious-disease-laboratory-bundle-dm"; 192 } 193 return null; 194 } 195 196 197 private boolean checkProperty(List<ValidationMessage> errors, JsonObject obj, String path, String name, boolean required, String type) { 198 JsonElement e = obj.get(name); 199 if (e != null) { 200 String t = e.type().toName(); 201 if (!type.equals(t)) { 202 logError(errors, ValidationMessage.NO_RULE_DATE, line(e), col(e), path+"."+name, IssueType.STRUCTURE, "Wrong Property Type in JSON Payload. Expected : "+type+" but found "+t, IssueSeverity.ERROR); 203 } else { 204 return true; 205 } 206 } else if (required) { 207 logError(errors, ValidationMessage.NO_RULE_DATE, line(obj), col(obj), path, IssueType.STRUCTURE, "Missing Property in JSON Payload: "+name, IssueSeverity.ERROR); 208 } else { 209 return true; 210 } 211 return false; 212 } 213 214 private void checkNamedProperties(List<ValidationMessage> errors, JsonObject obj, String path, String... names) { 215 for (JsonProperty e : obj.getProperties()) { 216 if (!Utilities.existsInList(e.getName(), names)) { 217 logError(errors, ValidationMessage.NO_RULE_DATE, line(e.getValue()), col(e.getValue()), path+"."+e.getName(), IssueType.STRUCTURE, "Unknown Property in JSON Payload", IssueSeverity.WARNING); 218 } 219 } 220 } 221 222 private int line(JsonElement e) { 223 return e.getStart().getLine(); 224 } 225 226 private int col(JsonElement e) { 227 return e.getStart().getCol(); 228 } 229 230 231 232 public void compose(Element e, OutputStream destination, OutputStyle style, String base) throws FHIRException, IOException { 233 throw new FHIRFormatError("Writing resources is not supported for the SHC format"); 234 // because then we'd have to try to sign, and we're just not going to be doing that from the element model 235 } 236 237 238 public static class JWT { 239 240 private JsonObject header; 241 private JsonObject payload; 242 243 private byte[] headerSrc; 244 private byte[] payloadSrc; 245 246 public JsonObject getHeader() { 247 return header; 248 } 249 public void setHeader(JsonObject header) { 250 this.header = header; 251 } 252 public JsonObject getPayload() { 253 return payload; 254 } 255 public void setPayload(JsonObject payload) { 256 this.payload = payload; 257 } 258 public byte[] getHeaderSrc() { 259 return headerSrc; 260 } 261 public void setHeaderSrc(byte[] headerSrc) { 262 this.headerSrc = headerSrc; 263 } 264 public byte[] getPayloadSrc() { 265 return payloadSrc; 266 } 267 public void setPayloadSrc(byte[] payloadSrc) { 268 this.payloadSrc = payloadSrc; 269 } 270 271 } 272 273 private static final int BUFFER_SIZE = 1024; 274 public static final String CURRENT_PACKAGE = "hl7.fhir.uv.shc-vaccination#0.6.2"; 275 private static final int MAX_ALLOWED_SHC_LENGTH = 1195; 276 277 // todo: deal with chunking 278 public static String decodeQRCode(String src) { 279 StringBuilder b = new StringBuilder(); 280 if (!src.startsWith("shc:/")) { 281 throw new FHIRException("Unable to process smart health card (didn't start with shc:/)"); 282 } 283 for (int i = 5; i < src.length(); i = i + 2) { 284 String s = src.substring(i, i+2); 285 byte v = Byte.parseByte(s); 286 char c = (char) (45+v); 287 b.append(c); 288 } 289 return b.toString(); 290 } 291 292 public JWT decodeJWT(List<ValidationMessage> errors, String jwt) throws IOException, DataFormatException { 293 if (jwt.startsWith("shc:/")) { 294 jwt = decodeQRCode(jwt); 295 } 296 if (jwt.length() > MAX_ALLOWED_SHC_LENGTH) { 297 logError(errors, ValidationMessage.NO_RULE_DATE, -1, -1, "jwt", IssueType.TOOLONG, "JWT Payload limit length is "+MAX_ALLOWED_SHC_LENGTH+" bytes for a single image - this has "+jwt.length()+" bytes", IssueSeverity.ERROR); 298 } 299 300 String[] parts = splitToken(jwt); 301 byte[] headerJson; 302 byte[] payloadJson; 303 try { 304 headerJson = Base64.getUrlDecoder().decode(parts[0]); 305 payloadJson = Base64.getUrlDecoder().decode(parts[1]); 306 } catch (NullPointerException e) { 307 throw new FHIRException("The UTF-8 Charset isn't initialized.", e); 308 } catch (IllegalArgumentException e){ 309 throw new FHIRException("The input is not a valid base 64 encoded string.", e); 310 } 311 JWT res = new JWT(); 312 res.setHeaderSrc(headerJson); 313 res.header = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(headerJson); 314 if ("DEF".equals(res.header.asString("zip"))) { 315 payloadJson = inflate(payloadJson); 316 } 317 res.setPayloadSrc(payloadJson); 318 res.payload = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(FileUtilities.bytesToString(payloadJson), true); 319 320 checkSignature(jwt, res, errors, "jwt", org.hl7.fhir.utilities.json.parser.JsonParser.compose(res.payload)); 321 return res; 322 } 323 324 private void checkSignature(String jwt, JWT res, List<ValidationMessage> errors, String name, String jsonPayload) { 325 String iss = res.payload.asString("iss"); 326 if (iss != null) { // reported elsewhere 327 if (!iss.startsWith("https://")) { 328 logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "JWT iss '"+iss+"' must start with https://", IssueSeverity.ERROR); 329 } 330 if (iss.endsWith("/")) { 331 logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "JWT iss '"+iss+"' must not have trailing /", IssueSeverity.ERROR); 332 iss = iss.substring(0, iss.length()-1); 333 } 334 String url = Utilities.pathURL(iss, "/.well-known/jwks.json"); 335 JsonObject jwks = null; 336 try { 337 jwks = signatureServices != null ? signatureServices.fetchJWKS(url) : org.hl7.fhir.utilities.json.parser.JsonParser.parseObjectFromUrl(url); 338 } catch (Exception e) { 339 logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "Unable to verify the signature, because unable to retrieve JWKS from "+url+": "+ 340 e.getMessage().replace("Connection refused (Connection refused)", "Connection refused"), IssueSeverity.ERROR); 341 } 342 if (jwks != null) { 343 verifySignature(jwt, errors, name, iss, url, org.hl7.fhir.utilities.json.parser.JsonParser.compose(jwks)); 344 } 345 346 // TODO Auto-generated method stub 347 348 // 349 // logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, 1, 1, prefix+"JWT", IssueType.INFORMATIONAL, "The FHIR Validator does not check the JWT signature "+ 350 // "(see https://demo-portals.smarthealth.cards/VerifierPortal.html or https://github.com/smart-on-fhir/health-cards-dev-tools) (Issuer = '"+jwt.getPayload().asString("iss")+"')", IssueSeverity.INFORMATION); 351 } 352 353 } 354 355 public class SHCSignedJWT extends com.nimbusds.jwt.SignedJWT { 356 private static final long serialVersionUID = 1L; 357 private JWTClaimsSet claimsSet; 358 359 public SHCSignedJWT(SignedJWT jwtO, String jsonPayload) throws ParseException { 360 super(jwtO.getParsedParts()[0], jwtO.getParsedParts()[1], jwtO.getParsedParts()[2]); 361 Map<String, Object> json = JSONObjectUtils.parse(jsonPayload); 362 claimsSet = JWTClaimsSet.parse(json); 363 } 364 365 public JWTClaimsSet getJWTClaimsSet() { 366 return claimsSet; 367 } 368 } 369 370 private void verifySignature(String jwt, List<ValidationMessage> errors, String name, String iss, String url, String jwks) { 371 try { 372 // Parse the JWS token 373 JWSObject jwsObject = JWSObject.parse(jwt); 374 375 // Extract header details 376 JWSHeader header = jwsObject.getHeader(); 377 validateHeader(header); 378 379 // Decompress the payload 380 byte[] decodedPayload = jwsObject.getPayload().toBytes(); 381 String decompressedPayload = decompress(decodedPayload); 382 383 // Extract issuer from the payload 384 JsonObject rootNode = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(decompressedPayload); 385 String issuer = rootNode.asString("iss"); 386 387 // Fetch the public key 388 JWKSet jwkSet = JWKSet.parse(jwks); 389 JWK publicKey = jwkSet.getKeyByKeyId(header.getKeyID()); 390 391 // Verify the JWS token 392 JWSVerifier verifier = new ECDSAVerifier((ECKey) publicKey); 393 if (jwsObject.verify(verifier)) { 394 String vciName = getVCIIssuer(errors, issuer); 395 if (vciName == null) { 396 logError(errors, "2023-09-08", 1, 1, name, IssueType.BUSINESSRULE, "The signature is valid, but the issuer "+issuer+" is not a trusted issuer", IssueSeverity.WARNING); 397 } else { 398 logError(errors, "2023-09-08", 1, 1, name, IssueType.INFORMATIONAL, "The signature is valid, signed by the trusted issuer '"+vciName+"' ("+issuer+")", IssueSeverity.INFORMATION); 399 } 400 } else { 401 logError(errors, "2023-09-08", 1, 1, name, IssueType.BUSINESSRULE, "The signature is not valid", IssueSeverity.ERROR); 402 } 403 } catch (Exception e) { 404 logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "Error validating signature: "+e.getMessage(), IssueSeverity.ERROR); 405 } 406 } 407 408 private static void validateHeader(JWSHeader header) { 409 if (!"ES256".equals(header.getAlgorithm().getName())) { 410 throw new IllegalArgumentException("Invalid alg in JWS header. Expected ES256."); 411 } 412 if (!header.getCustomParam("zip").equals("DEF")) { 413 throw new IllegalArgumentException("Invalid zip in JWS header. Expected DEF."); 414 } 415 } 416 417 private static String decompress(byte[] compressed) throws Exception { 418 Inflater inflater = new Inflater(true); 419 inflater.setInput(compressed); 420 421 byte[] buffer = new byte[1024]; 422 int length; 423 try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(compressed.length)) { 424 while (!inflater.finished()) { 425 length = inflater.inflate(buffer); 426 outputStream.write(buffer, 0, length); 427 } 428 return outputStream.toString(StandardCharsets.UTF_8.name()); 429 } 430 } 431 432 433 private String getVCIIssuer(List<ValidationMessage> errors, String issuer) { 434 try { 435 JsonObject vci = org.hl7.fhir.utilities.json.parser.JsonParser.parseObjectFromUrl("https://raw.githubusercontent.com/the-commons-project/vci-directory/main/vci-issuers.json"); 436 437 /* HTTPResult httpResult = ManagedWebAccess.httpCall( 438 new HTTPRequest().withMethod(HTTPVerb.GET).withUrl(new URL("https://raw.githubusercontent.com/the-commons-project/vci-directory/main/vci-issuers.json")) 439 new URL("https://raw.githubusercontent.com/the-commons-project/vci-directory/main/vci-issuers.json") 440 HTTPRequest.HttpMethod.GET, 441 null, 442 null, 443 null 444 445 ) 446 ) 447 */ 448 449 //JsonObject vci = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(); 450 for (JsonObject j : vci.getJsonObjects("participating_issuers")) { 451 if (issuer.equals(j.asString("iss"))) { 452 return j.asString("name"); 453 } 454 } 455 } catch (Exception e) { 456 logError(errors, "2023-09-08", 1, 1, "vci", IssueType.NOTFOUND, "Unable to retrieve/read VCI Trusted Issuer list: "+e.getMessage(), IssueSeverity.WARNING); 457 } 458 return null; 459 } 460 461 static String[] splitToken(String token) { 462 String[] parts = token.split("\\."); 463 if (parts.length == 2 && token.endsWith(".")) { 464 //Tokens with alg='none' have empty String as Signature. 465 parts = new String[]{parts[0], parts[1], ""}; 466 } 467 if (parts.length != 3) { 468 throw new FHIRException(String.format("The token was expected to have 3 parts, but got %s.", parts.length)); 469 } 470 return parts; 471 } 472 473 public static final byte[] inflate(byte[] data) throws IOException, DataFormatException { 474 final Inflater inflater = new Inflater(true); 475 inflater.setInput(data); 476 477 try (final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(data.length)) 478 { 479 byte[] buffer = new byte[BUFFER_SIZE]; 480 while (!inflater.finished()) 481 { 482 final int count = inflater.inflate(buffer); 483 outputStream.write(buffer, 0, count); 484 } 485 486 return outputStream.toByteArray(); 487 } 488 } 489 490 491}