SHC signature verification

This commit is contained in:
Grahame Grieve 2023-09-08 07:35:11 +10:00
parent ee6ec0e7d1
commit 6b38a730e9
3 changed files with 238 additions and 39 deletions

View File

@ -5,9 +5,15 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
@ -19,10 +25,12 @@ import org.hl7.fhir.exceptions.FHIRException;
import org.hl7.fhir.exceptions.FHIRFormatError;
import org.hl7.fhir.r5.context.IWorkerContext;
import org.hl7.fhir.r5.elementmodel.ParserBase.NamedElement;
import org.hl7.fhir.r5.elementmodel.SHCParser.SHCSignedJWT;
import org.hl7.fhir.r5.formats.IParser.OutputStyle;
import org.hl7.fhir.utilities.TextFile;
import org.hl7.fhir.utilities.Utilities;
import org.hl7.fhir.utilities.VersionUtilities;
import org.hl7.fhir.utilities.json.JsonException;
import org.hl7.fhir.utilities.json.model.JsonArray;
import org.hl7.fhir.utilities.json.model.JsonElement;
import org.hl7.fhir.utilities.json.model.JsonElementType;
@ -33,6 +41,20 @@ import org.hl7.fhir.utilities.validation.ValidationMessage;
import org.hl7.fhir.utilities.validation.ValidationMessage.IssueSeverity;
import org.hl7.fhir.utilities.validation.ValidationMessage.IssueType;
import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.ECDSAVerifier;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.BadJOSEException;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import com.nimbusds.jose.proc.JWSKeySelector;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.JSONObjectUtils;
import com.nimbusds.jwt.*;
import com.nimbusds.jwt.proc.*;
/**
* this class is actually a smart health cards validator.
* It's going to parse the JWT and assume that it contains
@ -52,7 +74,7 @@ public class SHCParser extends ParserBase {
private JsonParser jsonParser;
private List<String> types = new ArrayList<>();
public SHCParser(IWorkerContext context) {
super(context);
jsonParser = new JsonParser(context);
@ -64,24 +86,24 @@ public class SHCParser extends ParserBase {
List<NamedElement> res = new ArrayList<>();
NamedElement shc = new NamedElement("shc", "json", content);
res.add(shc);
String src = TextFile.streamToString(stream).trim();
List<String> list = new ArrayList<>();
String pfx = null;
if (src.startsWith("{")) {
JsonObject json = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(src);
if (checkProperty(shc.getErrors(), json, "$", "verifiableCredential", true, "Array")) {
pfx = "verifiableCredential";
JsonArray arr = json.getJsonArray("verifiableCredential");
int i = 0;
for (JsonElement e : arr) {
if (!(e instanceof JsonPrimitive)) {
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);
} else {
list.add(e.asString());
}
i++;
}
pfx = "verifiableCredential";
JsonArray arr = json.getJsonArray("verifiableCredential");
int i = 0;
for (JsonElement e : arr) {
if (!(e instanceof JsonPrimitive)) {
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);
} else {
list.add(e.asString());
}
i++;
}
} else {
return res;
}
@ -102,8 +124,6 @@ public class SHCParser extends ParserBase {
checkNamedProperties(shc.getErrors(), jwt.getPayload(), prefix+"payload", "iss", "nbf", "vc");
checkProperty(shc.getErrors(), jwt.getPayload(), prefix+"payload", "iss", true, "String");
logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, 1, 1, prefix+"JWT", IssueType.INFORMATIONAL, "The FHIR Validator does not check the JWT signature "+
"(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);
checkProperty(shc.getErrors(), jwt.getPayload(), prefix+"payload", "nbf", true, "Number");
JsonObject vc = jwt.getPayload().getJsonObject("vc");
if (vc == null) {
@ -167,7 +187,7 @@ public class SHCParser extends ParserBase {
}
return null;
}
private boolean checkProperty(List<ValidationMessage> errors, JsonObject obj, String path, String name, boolean required, String type) {
JsonElement e = obj.get(name);
@ -193,7 +213,7 @@ public class SHCParser extends ParserBase {
}
}
}
private int line(JsonElement e) {
return e.getStart().getLine();
}
@ -209,12 +229,12 @@ public class SHCParser extends ParserBase {
// because then we'd have to try to sign, and we're just not going to be doing that from the element model
}
public static class JWT {
private JsonObject header;
private JsonObject payload;
public JsonObject getHeader() {
return header;
}
@ -232,7 +252,7 @@ public class SHCParser extends ParserBase {
private static final int BUFFER_SIZE = 1024;
public static final String CURRENT_PACKAGE = "hl7.fhir.uv.shc-vaccination#0.6.2";
private static final int MAX_ALLOWED_SHC_LENGTH = 1195;
// todo: deal with chunking
public static String decodeQRCode(String src) {
StringBuilder b = new StringBuilder();
@ -273,9 +293,133 @@ public class SHCParser extends ParserBase {
payloadJson = inflate(payloadJson);
}
res.payload = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(TextFile.bytesToString(payloadJson), true);
checkSignature(jwt, res, errors, "jwt", org.hl7.fhir.utilities.json.parser.JsonParser.compose(res.payload));
return res;
}
private void checkSignature(String jwt, JWT res, List<ValidationMessage> errors, String name, String jsonPayload) {
String iss = res.payload.asString("iss");
if (iss != null) { // reported elsewhere
if (!iss.startsWith("https://")) {
logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "JWT iss '"+iss+"' must start with https://", IssueSeverity.ERROR);
}
if (iss.endsWith("/")) {
logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "JWT iss '"+iss+"' must not have trailing /", IssueSeverity.ERROR);
iss = iss.substring(0, iss.length()-1);
}
String url = Utilities.pathURL(iss, "/.well-known/jwks.json");
JsonObject jwks = null;
try {
jwks = org.hl7.fhir.utilities.json.parser.JsonParser.parseObjectFromUrl(url);
} catch (Exception e) {
logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "Unable to verify the signature, because unable to retrieve JWKS from "+url+": "+e.getMessage(), IssueSeverity.ERROR);
}
if (jwks != null) {
verifySignature(jwt, errors, name, iss, url, org.hl7.fhir.utilities.json.parser.JsonParser.compose(jwks));
}
// TODO Auto-generated method stub
//
// logError(shc.getErrors(), ValidationMessage.NO_RULE_DATE, 1, 1, prefix+"JWT", IssueType.INFORMATIONAL, "The FHIR Validator does not check the JWT signature "+
// "(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);
}
}
public class SHCSignedJWT extends com.nimbusds.jwt.SignedJWT {
private static final long serialVersionUID = 1L;
private JWTClaimsSet claimsSet;
public SHCSignedJWT(SignedJWT jwtO, String jsonPayload) throws ParseException {
super(jwtO.getParsedParts()[0], jwtO.getParsedParts()[1], jwtO.getParsedParts()[2]);
Map<String, Object> json = JSONObjectUtils.parse(jsonPayload);
claimsSet = JWTClaimsSet.parse(json);
}
public JWTClaimsSet getJWTClaimsSet() {
return claimsSet;
}
}
private void verifySignature(String jwt, List<ValidationMessage> errors, String name, String iss, String url, String jwks) {
try {
// Parse the JWS token
JWSObject jwsObject = JWSObject.parse(jwt);
// Extract header details
JWSHeader header = jwsObject.getHeader();
validateHeader(header);
// Decompress the payload
byte[] decodedPayload = jwsObject.getPayload().toBytes();
String decompressedPayload = decompress(decodedPayload);
// Extract issuer from the payload
JsonObject rootNode = org.hl7.fhir.utilities.json.parser.JsonParser.parseObject(decompressedPayload);
String issuer = rootNode.asString("iss");
// Fetch the public key
JWKSet jwkSet = JWKSet.parse(jwks);
JWK publicKey = jwkSet.getKeyByKeyId(header.getKeyID());
// Verify the JWS token
JWSVerifier verifier = new ECDSAVerifier((ECKey) publicKey);
if (jwsObject.verify(verifier)) {
String vciName = getVCIIssuer(errors, issuer);
if (vciName == null) {
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);
} else {
logError(errors, "2023-09-08", 1, 1, name, IssueType.INFORMATIONAL, "The signature is valid, signed by the trusted issuer '"+vciName+"' ("+issuer+")", IssueSeverity.INFORMATION);
}
} else {
logError(errors, "2023-09-08", 1, 1, name, IssueType.BUSINESSRULE, "The signature is not valid", IssueSeverity.ERROR);
}
} catch (Exception e) {
logError(errors, "2023-09-08", 1, 1, name, IssueType.NOTFOUND, "Error validating signature: "+e.getMessage(), IssueSeverity.ERROR);
}
}
private static void validateHeader(JWSHeader header) {
if (!"ES256".equals(header.getAlgorithm().getName())) {
throw new IllegalArgumentException("Invalid alg in JWS header. Expected ES256.");
}
if (!header.getCustomParam("zip").equals("DEF")) {
throw new IllegalArgumentException("Invalid zip in JWS header. Expected DEF.");
}
}
private static String decompress(byte[] compressed) throws Exception {
Inflater inflater = new Inflater(true);
inflater.setInput(compressed);
byte[] buffer = new byte[1024];
int length;
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(compressed.length)) {
while (!inflater.finished()) {
length = inflater.inflate(buffer);
outputStream.write(buffer, 0, length);
}
return outputStream.toString(StandardCharsets.UTF_8.name());
}
}
private String getVCIIssuer(List<ValidationMessage> errors, String issuer) {
try {
JsonObject vci = org.hl7.fhir.utilities.json.parser.JsonParser.parseObjectFromUrl("https://raw.githubusercontent.com/the-commons-project/vci-directory/main/vci-issuers.json");
for (JsonObject j : vci.getJsonObjects("participating_issuers")) {
if (issuer.equals(j.asString("iss"))) {
return j.asString("name");
}
}
} catch (Exception e) {
logError(errors, "2023-09-08", 1, 1, "vci", IssueType.NOTFOUND, "Unable to retrieve/read VCI Trusted Issuer list: "+e.getMessage(), IssueSeverity.WARNING);
}
return null;
}
static String[] splitToken(String token) {
String[] parts = token.split("\\.");
if (parts.length == 2 && token.endsWith(".")) {
@ -287,21 +431,21 @@ public class SHCParser extends ParserBase {
}
return parts;
}
public static final byte[] inflate(byte[] data) throws IOException, DataFormatException {
final Inflater inflater = new Inflater(true);
inflater.setInput(data);
try (final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(data.length))
{
byte[] buffer = new byte[BUFFER_SIZE];
while (!inflater.finished())
{
final int count = inflater.inflate(buffer);
outputStream.write(buffer, 0, count);
}
byte[] buffer = new byte[BUFFER_SIZE];
while (!inflater.finished())
{
final int count = inflater.inflate(buffer);
outputStream.write(buffer, 0, count);
}
return outputStream.toByteArray();
return outputStream.toByteArray();
}
}

View File

@ -359,7 +359,7 @@ public class SHLParser extends ParserBase {
long epochSecs = Long.valueOf(v);
LocalDateTime date = LocalDateTime.ofEpochSecond(epochSecs, 0, ZoneOffset.UTC);
LocalDateTime now = LocalDateTime.now(ZoneOffset.UTC);
Duration duration = Duration.between(now, date);
Duration duration = Duration.between(date, now);
if (date.isBefore(now)) {
logError(errors, "2023-08-31", p.getValue().getStart().getLine(), p.getValue().getStart().getCol(), "shl."+p.getName(),

View File

@ -119,7 +119,6 @@ v: {
"code" : "115",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
@ -141,7 +140,6 @@ v: {
"code" : "10",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
@ -163,7 +161,6 @@ v: {
"code" : "85",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
@ -185,7 +182,6 @@ v: {
"code" : "25",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
@ -207,7 +203,6 @@ v: {
"code" : "37",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
@ -229,7 +224,6 @@ v: {
"code" : "185",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
@ -251,7 +245,6 @@ v: {
"code" : "150",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
@ -273,7 +266,6 @@ v: {
"code" : "207",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
@ -295,7 +287,6 @@ v: {
"code" : "171",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
@ -317,6 +308,70 @@ v: {
"code" : "88",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"issues" : {
"resourceType" : "OperationOutcome"
}
}
-------------------------------------------------------------------------------------
{"code" : {
"system" : "http://hl7.org/fhir/sid/cvx",
"code" : "210"
}, "valueSet" :null, "langs":"", "useServer":"true", "useClient":"true", "guessSystem":"false", "valueSetMode":"ALL_CHECKS", "displayWarningMode":"false", "versionFlexible":"false", "profile": {
"resourceType" : "Parameters",
"parameter" : [{
"name" : "profile-url",
"valueString" : "http://hl7.org/fhir/ExpansionProfile/dc8fd4bc-091a-424a-8a3b-6198ef146891"
}]
}}####
v: {
"display" : "SARS-COV-2 (COVID-19) vaccine, vector non-replicating, recombinant spike protein-ChAdOx1, preservative free, 0.5 mL",
"code" : "210",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"issues" : {
"resourceType" : "OperationOutcome"
}
}
-------------------------------------------------------------------------------------
{"code" : {
"system" : "http://hl7.org/fhir/sid/cvx",
"code" : "210"
}, "url": "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/vaccine-cvx", "version": "0.6.2", "langs":"", "useServer":"true", "useClient":"true", "guessSystem":"false", "valueSetMode":"CHECK_MEMERSHIP_ONLY", "displayWarningMode":"false", "versionFlexible":"false", "profile": {
"resourceType" : "Parameters",
"parameter" : [{
"name" : "profile-url",
"valueString" : "http://hl7.org/fhir/ExpansionProfile/dc8fd4bc-091a-424a-8a3b-6198ef146891"
}]
}}####
v: {
"display" : "SARS-COV-2 (COVID-19) vaccine, vector non-replicating, recombinant spike protein-ChAdOx1, preservative free, 0.5 mL",
"code" : "210",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"
}
}
-------------------------------------------------------------------------------------
{"code" : {
"system" : "http://hl7.org/fhir/sid/cvx",
"code" : "207"
}, "url": "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/vaccine-cvx", "version": "0.6.2", "langs":"", "useServer":"true", "useClient":"true", "guessSystem":"false", "valueSetMode":"CHECK_MEMERSHIP_ONLY", "displayWarningMode":"false", "versionFlexible":"false", "profile": {
"resourceType" : "Parameters",
"parameter" : [{
"name" : "profile-url",
"valueString" : "http://hl7.org/fhir/ExpansionProfile/dc8fd4bc-091a-424a-8a3b-6198ef146891"
}]
}}####
v: {
"display" : "SARS-COV-2 (COVID-19) vaccine, mRNA, spike protein, LNP, preservative free, 100 mcg/0.5mL dose",
"code" : "207",
"system" : "http://hl7.org/fhir/sid/cvx",
"version" : "20210406",
"unknown-systems" : "",
"issues" : {
"resourceType" : "OperationOutcome"