All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.hl7.fhir.r5.elementmodel.SHCParser Maven / Gradle / Ivy

package org.hl7.fhir.r5.elementmodel;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;

import org.hl7.fhir.exceptions.DefinitionException;
import org.hl7.fhir.exceptions.FHIRException;
import org.hl7.fhir.exceptions.FHIRFormatError;
import org.hl7.fhir.r5.context.IWorkerContext;
import org.hl7.fhir.r5.elementmodel.SHCParser.SHCSignedJWT;
import org.hl7.fhir.r5.formats.IParser.OutputStyle;
import org.hl7.fhir.utilities.FileUtilities;
import org.hl7.fhir.utilities.MarkedToMoveToAdjunctPackage;
import org.hl7.fhir.utilities.Utilities;
import org.hl7.fhir.utilities.VersionUtilities;
import org.hl7.fhir.utilities.http.HTTPRequest;
import org.hl7.fhir.utilities.http.HTTPResult;
import org.hl7.fhir.utilities.http.ManagedWebAccess;
import org.hl7.fhir.utilities.json.JsonException;
import org.hl7.fhir.utilities.json.model.JsonArray;
import org.hl7.fhir.utilities.json.model.JsonElement;
import org.hl7.fhir.utilities.json.model.JsonElementType;
import org.hl7.fhir.utilities.json.model.JsonObject;
import org.hl7.fhir.utilities.json.model.JsonPrimitive;
import org.hl7.fhir.utilities.json.model.JsonProperty;
import org.hl7.fhir.utilities.validation.ValidationMessage;
import org.hl7.fhir.utilities.validation.ValidationMessage.IssueSeverity;
import org.hl7.fhir.utilities.validation.ValidationMessage.IssueType;

import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.ECDSAVerifier;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.BadJOSEException;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.JSONObjectUtils;
import com.nimbusds.jwt.*;
import com.nimbusds.jwt.proc.*;
/**
 * this class is actually a smart health cards validator. 
 * It's going to parse the JWT and assume that it contains 
 * a smart health card, which has a nested bundle in it, and 
 * then validate the bundle. 
 * 
 * See https://spec.smarthealth.cards/#health-cards-are-encoded-as-compact-serialization-json-web-signatures-jws
 * 
 * This parser dose the JWT work, and then passes the JsonObject through to the underlying JsonParser
 *
 * Error locations are in the decoded payload
 * 
 * @author grahame
 *
 */
@MarkedToMoveToAdjunctPackage
public class SHCParser extends ParserBase {

  private JsonParser jsonParser;
  private List types = new ArrayList<>();

  public SHCParser(IWorkerContext context) {
    super(context);
    jsonParser = new JsonParser(context);
  }

  public List parse(InputStream inStream) throws IOException, FHIRFormatError, DefinitionException, FHIRException {
    byte[] content = FileUtilities.streamToBytes(inStream);
    ByteArrayInputStream stream = new ByteArrayInputStream(content);
    List res = new ArrayList<>();
    ValidatedFragment shc = new ValidatedFragment("shc", "txt", content, false);
    res.add(shc);

    String src = FileUtilities.streamToString(stream).trim();
    List list = new ArrayList<>();
    String pfx = null;
    if (src.startsWith("{")) {
      JsonObject json = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(src);
      if (checkProperty(shc.getErrors(), json, "$", "verifiableCredential", true, "Array")) {
        pfx = "verifiableCredential";
        JsonArray arr = json.getJsonArray("verifiableCredential");
        int i = 0;
        for (JsonElement e : arr) {
          if (!(e instanceof JsonPrimitive)) {
            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);                
          } else {
            list.add(e.asString());
          }
          i++;
        }
      } else {
        return res;
      }      
    } else {
      list.add(src);
    }
    int c = 0;
    for (String ssrc : list) {
      String prefix = pfx == null ? "" : pfx+"["+Integer.toString(c)+"].";
      c++;
      JWT jwt = null;
      try {
        jwt = decodeJWT(shc.getErrors(), ssrc);
      } catch (Exception e) {
        logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, 1, 1, prefix+"JWT", IssueType.INVALID, "Unable to decode JWT token", IssueSeverity.ERROR);
        return res;      
      }

      ValidatedFragment bnd = new ValidatedFragment("payload", "json", jwt.payloadSrc, true);
      res.add(bnd);
      checkNamedProperties(shc.getErrors(), jwt.getPayload(), prefix+"payload", "iss", "nbf", "vc");
      checkProperty(shc.getErrors(), jwt.getPayload(), prefix+"payload", "iss", true, "String");
      checkProperty(shc.getErrors(), jwt.getPayload(), prefix+"payload", "nbf", true, "Number");
      JsonObject vc = jwt.getPayload().getJsonObject("vc");
      if (vc == null) {
        logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, 1, 1, "JWT", IssueType.STRUCTURE, "Unable to find property 'vc' in the payload", IssueSeverity.ERROR);
        return res;
      }
      String path = prefix+"payload.vc";
      checkNamedProperties(shc.getErrors(), vc, path, "type", "credentialSubject");
      if (!checkProperty(shc.getErrors(), vc, path, "type", true, "Array")) {
        return res;
      }
      JsonArray type = vc.getJsonArray("type");
      int i = 0;
      for (JsonElement e : type) {
        if (e.type() != JsonElementType.STRING) {
          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);
        } else {
          types.add(e.asString());
        }
        i++;
      }
      if (!types.contains("https://smarthealth.cards#health-card")) {
        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);
        return res;
      }
      if (!checkProperty(shc.getErrors(), vc, path, "credentialSubject", true, "Object")) {
        return res;
      }
      JsonObject cs = vc.getJsonObject("credentialSubject");
      path = path+".credentialSubject";
      if (!checkProperty(shc.getErrors(), cs, path, "fhirVersion", true, "String")) {
        return res;
      }
      JsonElement fv = cs.get("fhirVersion");
      if (!VersionUtilities.versionsCompatible(context.getVersion(), fv.asString())) {
        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);
        return res;
      }
      if (!checkProperty(shc.getErrors(), cs, path, "fhirBundle", true, "Object")) {
        return res;
      }
      // ok. all checks passed, we can now validate the bundle
      bnd.setElement(jsonParser.parse(bnd.getErrors(), cs.getJsonObject("fhirBundle"), path));
      bnd.setElementPath(path);
    }  
    return res;
  }


  @Override
  public String getImpliedProfile() {
    if (types.contains("https://smarthealth.cards#covid19") && types.contains("https://smarthealth.cards#immunization")) {
      return "http://hl7.org/fhir/uv/shc-vaccination/StructureDefinition/shc-vaccination-bundle-dm";
    }
    if (types.contains("https://smarthealth.cards#covid19") && types.contains("https://smarthealth.cards#laboratory")) {
      return "http://hl7.org/fhir/uv/shc-vaccination/StructureDefinition/shc-covid19-laboratory-bundle-dm";
    }
    if (types.contains("https://smarthealth.cards#laboratory")) {
      return "http://hl7.org/fhir/uv/shc-vaccination/StructureDefinition/shc-infectious-disease-laboratory-bundle-dm";
    }
    return null;
  }


  private boolean checkProperty(List errors, JsonObject obj, String path, String name, boolean required, String type) {
    JsonElement e = obj.get(name);
    if (e != null) {
      String t = e.type().toName();
      if (!type.equals(t)) {
        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);                
      } else {
        return true;
      }
    } else if (required) {
      logError(errors, ValidationMessage.NO_RULE_DATE, line(obj), col(obj), path, IssueType.STRUCTURE, "Missing Property in JSON Payload: "+name, IssueSeverity.ERROR);                
    } else {
      return true;
    }
    return false;
  }

  private void checkNamedProperties(List errors, JsonObject obj, String path, String... names) {
    for (JsonProperty e : obj.getProperties()) {
      if (!Utilities.existsInList(e.getName(), names)) {
        logError(errors, ValidationMessage.NO_RULE_DATE, line(e.getValue()), col(e.getValue()), path+"."+e.getName(), IssueType.STRUCTURE, "Unknown Property in JSON Payload", IssueSeverity.WARNING);                
      }
    }
  }

  private int line(JsonElement e) {
    return e.getStart().getLine();
  }

  private int col(JsonElement e) {
    return e.getStart().getCol();
  }



  public void compose(Element e, OutputStream destination, OutputStyle style, String base)  throws FHIRException, IOException {
    throw new FHIRFormatError("Writing resources is not supported for the SHC format");
    // because then we'd have to try to sign, and we're just not going to be doing that from the element model
  }


  public static class JWT {

    private JsonObject header;
    private JsonObject payload;

    private byte[] headerSrc;
    private byte[] payloadSrc;

    public JsonObject getHeader() {
      return header;
    }
    public void setHeader(JsonObject header) {
      this.header = header;
    }
    public JsonObject getPayload() {
      return payload;
    }
    public void setPayload(JsonObject payload) {
      this.payload = payload;
    }
    public byte[] getHeaderSrc() {
      return headerSrc;
    }
    public void setHeaderSrc(byte[] headerSrc) {
      this.headerSrc = headerSrc;
    }
    public byte[] getPayloadSrc() {
      return payloadSrc;
    }
    public void setPayloadSrc(byte[] payloadSrc) {
      this.payloadSrc = payloadSrc;
    }
    
  }

  private static final int BUFFER_SIZE = 1024;
  public static final String CURRENT_PACKAGE = "hl7.fhir.uv.shc-vaccination#0.6.2";
  private static final int MAX_ALLOWED_SHC_LENGTH = 1195;

  // todo: deal with chunking
  public static String decodeQRCode(String src) {
    StringBuilder b = new StringBuilder();
    if (!src.startsWith("shc:/")) {
      throw new FHIRException("Unable to process smart health card (didn't start with shc:/)");
    }
    for (int i = 5; i < src.length(); i = i + 2) {
      String s = src.substring(i, i+2);
      byte v = Byte.parseByte(s);
      char c = (char) (45+v);
      b.append(c);
    }
    return b.toString();
  }

  public JWT decodeJWT(List errors, String jwt) throws IOException, DataFormatException {
    if (jwt.startsWith("shc:/")) {
      jwt = decodeQRCode(jwt);
    }
    if (jwt.length() > MAX_ALLOWED_SHC_LENGTH) {
      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);
    }

    String[] parts = splitToken(jwt);
    byte[] headerJson;
    byte[] payloadJson;
    try {
      headerJson = Base64.getUrlDecoder().decode(parts[0]);
      payloadJson = Base64.getUrlDecoder().decode(parts[1]);
    } catch (NullPointerException e) {
      throw new FHIRException("The UTF-8 Charset isn't initialized.", e);
    } catch (IllegalArgumentException e){
      throw new FHIRException("The input is not a valid base 64 encoded string.", e);
    }
    JWT res = new JWT();
    res.setHeaderSrc(headerJson);
    res.header = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(headerJson);
    if ("DEF".equals(res.header.asString("zip"))) {
      payloadJson = inflate(payloadJson);
    }
    res.setPayloadSrc(payloadJson);
    res.payload = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(FileUtilities.bytesToString(payloadJson), true);

    checkSignature(jwt, res, errors, "jwt", org.hl7.fhir.utilities.json.parser.JsonParser.compose(res.payload));
    return res;
  }

  private void checkSignature(String jwt, JWT res, List errors, String name, String jsonPayload) {
    String iss = res.payload.asString("iss");
    if (iss != null) { // reported elsewhere
      if (!iss.startsWith("https://")) {
        logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "JWT iss '"+iss+"' must start with https://", IssueSeverity.ERROR);
      }
      if (iss.endsWith("/")) {
        logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "JWT iss '"+iss+"' must not have trailing /", IssueSeverity.ERROR);
        iss = iss.substring(0, iss.length()-1);
      }
      String url = Utilities.pathURL(iss, "/.well-known/jwks.json");
      JsonObject jwks = null;
      try {
        jwks = signatureServices != null ? signatureServices.fetchJWKS(url) : org.hl7.fhir.utilities.json.parser.JsonParser.parseObjectFromUrl(url);
      } catch (Exception e) {
        logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "Unable to verify the signature, because unable to retrieve JWKS from "+url+": "+
           e.getMessage().replace("Connection refused (Connection refused)", "Connection refused"), IssueSeverity.ERROR);    
      }
      if (jwks != null) {
        verifySignature(jwt, errors, name, iss, url, org.hl7.fhir.utilities.json.parser.JsonParser.compose(jwks));
      }

      // TODO Auto-generated method stub

      //
      //    logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, 1, 1, prefix+"JWT", IssueType.INFORMATIONAL, "The FHIR Validator does not check the JWT signature "+
      //        "(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);
    }

  }

  public class SHCSignedJWT extends com.nimbusds.jwt.SignedJWT {
    private static final long serialVersionUID = 1L;
    private JWTClaimsSet claimsSet;

    public SHCSignedJWT(SignedJWT jwtO, String jsonPayload) throws ParseException {
      super(jwtO.getParsedParts()[0], jwtO.getParsedParts()[1], jwtO.getParsedParts()[2]);
      Map json = JSONObjectUtils.parse(jsonPayload);
      claimsSet = JWTClaimsSet.parse(json);
    }

    public JWTClaimsSet getJWTClaimsSet() {
      return claimsSet;
    }
  }

  private void verifySignature(String jwt, List errors, String name, String iss, String url, String jwks) {
    try {
      // Parse the JWS token
      JWSObject jwsObject = JWSObject.parse(jwt);

      // Extract header details
      JWSHeader header = jwsObject.getHeader();
      validateHeader(header);

      // Decompress the payload
      byte[] decodedPayload = jwsObject.getPayload().toBytes();
      String decompressedPayload = decompress(decodedPayload);

      // Extract issuer from the payload
      JsonObject rootNode = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(decompressedPayload);
      String issuer = rootNode.asString("iss");

      // Fetch the public key
      JWKSet jwkSet = JWKSet.parse(jwks);
      JWK publicKey = jwkSet.getKeyByKeyId(header.getKeyID());

      // Verify the JWS token
      JWSVerifier verifier = new ECDSAVerifier((ECKey) publicKey);
      if (jwsObject.verify(verifier)) {
        String vciName = getVCIIssuer(errors, issuer);
        if (vciName == null) {
          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);
        } else {
          logError(errors, "2023-09-08", 1, 1, name, IssueType.INFORMATIONAL, "The signature is valid, signed by the trusted issuer '"+vciName+"' ("+issuer+")", IssueSeverity.INFORMATION);
        } 
      } else {
        logError(errors, "2023-09-08", 1, 1, name, IssueType.BUSINESSRULE, "The signature is not valid", IssueSeverity.ERROR);
      }
    } catch (Exception e) {
      logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "Error validating signature: "+e.getMessage(), IssueSeverity.ERROR);
    }
  }

  private static void validateHeader(JWSHeader header) {
    if (!"ES256".equals(header.getAlgorithm().getName())) {
      throw new IllegalArgumentException("Invalid alg in JWS header. Expected ES256.");
    }
    if (!header.getCustomParam("zip").equals("DEF")) {
      throw new IllegalArgumentException("Invalid zip in JWS header. Expected DEF.");
    }
  }

  private static String decompress(byte[] compressed) throws Exception {
    Inflater inflater = new Inflater(true);
    inflater.setInput(compressed);

    byte[] buffer = new byte[1024];
    int length;
    try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(compressed.length)) {
      while (!inflater.finished()) {
        length = inflater.inflate(buffer);
        outputStream.write(buffer, 0, length);
      }
      return outputStream.toString(StandardCharsets.UTF_8.name());
    }
  }


  private String getVCIIssuer(List errors, String issuer) {
    try {
      JsonObject vci = org.hl7.fhir.utilities.json.parser.JsonParser.parseObjectFromUrl("https://raw.githubusercontent.com/the-commons-project/vci-directory/main/vci-issuers.json");

      /* HTTPResult httpResult = ManagedWebAccess.httpCall(
        new HTTPRequest().withMethod(HTTPVerb.GET).withUrl(new URL("https://raw.githubusercontent.com/the-commons-project/vci-directory/main/vci-issuers.json"))
          new URL("https://raw.githubusercontent.com/the-commons-project/vci-directory/main/vci-issuers.json")
          HTTPRequest.HttpMethod.GET,
          null,
          null,
          null

        )
      )
      */

      //JsonObject vci = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject();
      for (JsonObject j : vci.getJsonObjects("participating_issuers")) {
        if (issuer.equals(j.asString("iss"))) {
          return j.asString("name");
        }
      }
    } catch (Exception e) {
      logError(errors, "2023-09-08", 1, 1, "vci", IssueType.NOTFOUND, "Unable to retrieve/read VCI Trusted Issuer list: "+e.getMessage(), IssueSeverity.WARNING);
    }
    return null;
  }

  static String[] splitToken(String token) {
    String[] parts = token.split("\\.");
    if (parts.length == 2 && token.endsWith(".")) {
      //Tokens with alg='none' have empty String as Signature.
      parts = new String[]{parts[0], parts[1], ""};
    }
    if (parts.length != 3) {
      throw new FHIRException(String.format("The token was expected to have 3 parts, but got %s.", parts.length));
    }
    return parts;
  }

  public static final byte[] inflate(byte[] data) throws IOException, DataFormatException {
    final Inflater inflater = new Inflater(true);
    inflater.setInput(data);

    try (final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(data.length))
    {
      byte[] buffer = new byte[BUFFER_SIZE];
      while (!inflater.finished())
      {
        final int count = inflater.inflate(buffer);
        outputStream.write(buffer, 0, count);
      }

      return outputStream.toByteArray();
    }
  }


}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy