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