SHC signature verification
This commit is contained in:
parent
ee6ec0e7d1
commit
6b38a730e9
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue