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}