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}