001package org.hl7.fhir.r5.elementmodel;
002
003import java.io.ByteArrayOutputStream;
004import java.io.IOException;
005import java.io.InputStream;
006import java.io.OutputStream;
007import java.util.ArrayList;
008import java.util.Base64;
009import java.util.HashMap;
010import java.util.List;
011import java.util.Map;
012import java.util.Map.Entry;
013import java.util.zip.DataFormatException;
014import java.util.zip.Inflater;
015
016import org.hl7.fhir.exceptions.DefinitionException;
017import org.hl7.fhir.exceptions.FHIRException;
018import org.hl7.fhir.exceptions.FHIRFormatError;
019import org.hl7.fhir.r5.context.IWorkerContext;
020import org.hl7.fhir.r5.formats.IParser.OutputStyle;
021import org.hl7.fhir.utilities.TextFile;
022import org.hl7.fhir.utilities.Utilities;
023import org.hl7.fhir.utilities.VersionUtilities;
024import org.hl7.fhir.utilities.json.model.JsonArray;
025import org.hl7.fhir.utilities.json.model.JsonElement;
026import org.hl7.fhir.utilities.json.model.JsonElementType;
027import org.hl7.fhir.utilities.json.model.JsonObject;
028import org.hl7.fhir.utilities.json.model.JsonPrimitive;
029import org.hl7.fhir.utilities.json.model.JsonProperty;
030import org.hl7.fhir.utilities.validation.ValidationMessage;
031import org.hl7.fhir.utilities.validation.ValidationMessage.IssueSeverity;
032import org.hl7.fhir.utilities.validation.ValidationMessage.IssueType;
033
034/**
035 * this class is actually a smart health cards validator. 
036 * It's going to parse the JWT and assume that it contains 
037 * a smart health card, which has a nested bundle in it, and 
038 * then validate the bundle. 
039 * 
040 * See https://spec.smarthealth.cards/#health-cards-are-encoded-as-compact-serialization-json-web-signatures-jws
041 * 
042 * This parser dose the JWT work, and then passes the JsonObject through to the underlying JsonParser
043 *
044 * Error locations are in the decoded payload
045 * 
046 * @author grahame
047 *
048 */
049public class SHCParser extends ParserBase {
050
051  private JsonParser jsonParser;
052  private List<String> types = new ArrayList<>();
053  
054  public SHCParser(IWorkerContext context) {
055    super(context);
056    jsonParser = new JsonParser(context);
057  }
058
059  public List<NamedElement> parse(InputStream stream) throws IOException, FHIRFormatError, DefinitionException, FHIRException {
060    List<NamedElement> res = new ArrayList<>();
061    String src = TextFile.streamToString(stream).trim();
062    List<String> list = new ArrayList<>();
063    String pfx = null;
064    if (src.startsWith("{")) {
065      JsonObject json = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(src);
066      if (checkProperty(json, "$", "verifiableCredential", true, "Array")) {
067       pfx = "verifiableCredential";
068       JsonArray arr = json.getJsonArray("verifiableCredential");
069       int i = 0;
070       for (JsonElement e : arr) {
071         if (!(e instanceof JsonPrimitive)) {
072           logError(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);                
073         } else {
074           list.add(e.asString());
075         }
076         i++;
077       }
078      } else {
079        return res;
080      }      
081    } else {
082      list.add(src);
083    }
084    int c = 0;
085    for (String ssrc : list) {
086      String prefix = pfx == null ? "" : pfx+"["+Integer.toString(c)+"].";
087      c++;
088      JWT jwt = null;
089      try {
090        jwt = decodeJWT(ssrc);
091      } catch (Exception e) {
092        logError(ValidationMessage.NO_RULE_DATE, 1, 1, prefix+"JWT", IssueType.INVALID, "Unable to decode JWT token", IssueSeverity.ERROR);
093        return res;      
094      }
095
096      checkNamedProperties(jwt.getPayload(), prefix+"payload", "iss", "nbf", "vc");
097      checkProperty(jwt.getPayload(), prefix+"payload", "iss", true, "String");
098      logError(ValidationMessage.NO_RULE_DATE, 1, 1, prefix+"JWT", IssueType.INFORMATIONAL, "The FHIR Validator does not check the JWT signature "+
099          "(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);
100      checkProperty(jwt.getPayload(), prefix+"payload", "nbf", true, "Number");
101      JsonObject vc = jwt.getPayload().getJsonObject("vc");
102      if (vc == null) {
103        logError(ValidationMessage.NO_RULE_DATE, 1, 1, "JWT", IssueType.STRUCTURE, "Unable to find property 'vc' in the payload", IssueSeverity.ERROR);
104        return res;
105      }
106      String path = prefix+"payload.vc";
107      checkNamedProperties(vc, path, "type", "credentialSubject");
108      if (!checkProperty(vc, path, "type", true, "Array")) {
109        return res;
110      }
111      JsonArray type = vc.getJsonArray("type");
112      int i = 0;
113      for (JsonElement e : type) {
114        if (e.type() != JsonElementType.STRING) {
115          logError(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);
116        } else {
117          types.add(e.asString());
118        }
119        i++;
120      }
121      if (!types.contains("https://smarthealth.cards#health-card")) {
122        logError(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);
123        return res;
124      }
125      if (!checkProperty(vc, path, "credentialSubject", true, "Object")) {
126        return res;
127      }
128      JsonObject cs = vc.getJsonObject("credentialSubject");
129      path = path+".credentialSubject";
130      if (!checkProperty(cs, path, "fhirVersion", true, "String")) {
131        return res;
132      }
133      JsonElement fv = cs.get("fhirVersion");
134      if (!VersionUtilities.versionsCompatible(context.getVersion(), fv.asString())) {
135        logError(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);
136        return res;
137      }
138      if (!checkProperty(cs, path, "fhirBundle", true, "Object")) {
139        return res;
140      }
141      // ok. all checks passed, we can now validate the bundle
142      Element e = jsonParser.parse(cs.getJsonObject("fhirBundle"));
143      if (e != null) {
144        res.add(new NamedElement(path, e));
145      }
146    }
147    return res;
148  }
149  
150
151  @Override
152  public String getImpliedProfile() {
153    if (types.contains("https://smarthealth.cards#covid19") && types.contains("https://smarthealth.cards#immunization")) {
154      return "http://hl7.org/fhir/uv/shc-vaccination/StructureDefinition/shc-vaccination-bundle-dm";
155    }
156    if (types.contains("https://smarthealth.cards#covid19") && types.contains("https://smarthealth.cards#laboratory")) {
157      return "http://hl7.org/fhir/uv/shc-vaccination/StructureDefinition/shc-covid19-laboratory-bundle-dm";
158    }
159    if (types.contains("https://smarthealth.cards#laboratory")) {
160      return "http://hl7.org/fhir/uv/shc-vaccination/StructureDefinition/shc-infectious-disease-laboratory-bundle-dm";
161    }
162    return null;
163  }
164  
165
166  private boolean checkProperty(JsonObject obj, String path, String name, boolean required, String type) {
167    JsonElement e = obj.get(name);
168    if (e != null) {
169      String t = e.type().toName();
170      if (!type.equals(t)) {
171        logError(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);                
172      } else {
173        return true;
174      }
175    } else if (required) {
176      logError(ValidationMessage.NO_RULE_DATE, line(obj), col(obj), path, IssueType.STRUCTURE, "Missing Property in JSON Payload: "+name, IssueSeverity.ERROR);                
177    } else {
178      return true;
179    }
180    return false;
181  }
182
183  private void checkNamedProperties(JsonObject obj, String path, String... names) {
184    for (JsonProperty e : obj.getProperties()) {
185      if (!Utilities.existsInList(e.getName(), names)) {
186        logError(ValidationMessage.NO_RULE_DATE, line(e.getValue()), col(e.getValue()), path+"."+e.getName(), IssueType.STRUCTURE, "Unknown Property in JSON Payload", IssueSeverity.WARNING);                
187      }
188    }
189  }
190  
191  private int line(JsonElement e) {
192    return e.getStart().getLine();
193  }
194
195  private int col(JsonElement e) {
196    return e.getStart().getCol();
197  }
198
199
200
201  public void compose(Element e, OutputStream destination, OutputStyle style, String base)  throws FHIRException, IOException {
202    throw new FHIRFormatError("Writing resources is not supported for the SHC format");
203    // because then we'd have to try to sign, and we're just not going to be doing that from the element model
204  }
205
206  
207  public static class JWT {
208
209    private JsonObject header;
210    private JsonObject payload;
211    
212    public JsonObject getHeader() {
213      return header;
214    }
215    public void setHeader(JsonObject header) {
216      this.header = header;
217    }
218    public JsonObject getPayload() {
219      return payload;
220    }
221    public void setPayload(JsonObject payload) {
222      this.payload = payload;
223    }
224  }
225
226  private static final int BUFFER_SIZE = 1024;
227  public static final String CURRENT_PACKAGE = "hl7.fhir.uv.shc-vaccination#0.6.2";
228  private static final int MAX_ALLOWED_SHC_LENGTH = 1195;
229  
230  // todo: deal with chunking
231  public static String decodeQRCode(String src) {
232    StringBuilder b = new StringBuilder();
233    if (!src.startsWith("shc:/")) {
234      throw new FHIRException("Unable to process smart health card (didn't start with shc:/)");
235    }
236    for (int i = 5; i < src.length(); i = i + 2) {
237      String s = src.substring(i, i+2);
238      byte v = Byte.parseByte(s);
239      char c = (char) (45+v);
240      b.append(c);
241    }
242    return b.toString();
243  }
244
245  public JWT decodeJWT(String jwt) throws IOException, DataFormatException {
246    if (jwt.startsWith("shc:/")) {
247      jwt = decodeQRCode(jwt);
248    }
249    if (jwt.length() > MAX_ALLOWED_SHC_LENGTH) {
250      logError(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);
251    }
252
253    String[] parts = splitToken(jwt);
254    byte[] headerJson;
255    byte[] payloadJson;
256    try {
257      headerJson = Base64.getUrlDecoder().decode(parts[0]);
258      payloadJson = Base64.getUrlDecoder().decode(parts[1]);
259    } catch (NullPointerException e) {
260      throw new FHIRException("The UTF-8 Charset isn't initialized.", e);
261    } catch (IllegalArgumentException e){
262      throw new FHIRException("The input is not a valid base 64 encoded string.", e);
263    }
264    JWT res = new JWT();
265    res.header = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(headerJson);
266    if ("DEF".equals(res.header.asString("zip"))) {
267      payloadJson = inflate(payloadJson);
268    }
269    res.payload = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(TextFile.bytesToString(payloadJson), true);
270    return res;
271  }
272  
273  static String[] splitToken(String token) {
274    String[] parts = token.split("\\.");
275    if (parts.length == 2 && token.endsWith(".")) {
276      //Tokens with alg='none' have empty String as Signature.
277      parts = new String[]{parts[0], parts[1], ""};
278    }
279    if (parts.length != 3) {
280      throw new FHIRException(String.format("The token was expected to have 3 parts, but got %s.", parts.length));
281    }
282    return parts;
283  }
284  
285  public static final byte[] inflate(byte[] data) throws IOException, DataFormatException {
286    final Inflater inflater = new Inflater(true);
287    inflater.setInput(data);
288
289    try (final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(data.length))
290    {
291        byte[] buffer = new byte[BUFFER_SIZE];
292        while (!inflater.finished())
293        {
294            final int count = inflater.inflate(buffer);
295            outputStream.write(buffer, 0, count);
296        }
297
298        return outputStream.toByteArray();
299    }
300  }
301
302
303}