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}