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