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}