diff --git a/README.md b/README.md index 8efdaa1..1fa2c07 100644 --- a/README.md +++ b/README.md @@ -42,18 +42,44 @@ Include as Maven Dependency in pom.xml ``` +If you do not need the DCC Gateway Connector feature you can exclude some dependencies by adding an exclusions tag: + +```xml + + + + eu.europa.ec.dgc + dgc-lib + 1.0.0-SNAPSHOT + + + org.springframework.cloud + * + + + io.github.openfeign + * + + + + ... + +``` + ### Authenticating to GitHub Packages **Attention:** -GitHub does not allow anonymous access to it's package registry. You need to authenticate in order to use the dgc-lib artefact provided by us. -Therefore you need to authenticate to [GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry) +GitHub does not allow anonymous access to it's package registry. You need to authenticate in order to use the dgc-lib +artefact provided by us. Therefore you need to authenticate +to [GitHub Packages](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry) The following steps need to be performed - Create [PAT](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) with scopes: - - `read:packages` for downloading packages -- Copy/Augment `~/.m2/settings.xml` with the contents of `settings.xml` present in this repository (or in the DGC repository you are trying to build) - - Replace `${app.packages.username}` with your github username - - Replace `${app.packages.password}` with the generated PAT + - `read:packages` for downloading packages +- Copy/Augment `~/.m2/settings.xml` with the contents of `settings.xml` present in this repository (or in the DGC + repository you are trying to build) + - Replace `${app.packages.username}` with your github username + - Replace `${app.packages.password}` with the generated PAT ## Development diff --git a/pom.xml b/pom.xml index 076e76a..bcf829c 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,8 @@ 5.7.1 1.4.2.Final 2.8.0 + 4.4.3 + 2.11.4 3.1.2 3.6.1.1688 @@ -74,17 +76,19 @@ org.springframework.boot spring-boot-starter ${spring.boot.version} + true org.springframework.boot spring-boot-starter-web ${spring.boot.version} + true org.springframework.boot spring-boot-configuration-processor - true ${spring.boot.version} + true org.springframework.cloud @@ -112,6 +116,21 @@ bcpkix-jdk15on ${bcpkix.version} + + commons-io + commons-io + ${commonsio.version} + + + com.upokecenter + cbor + ${cbor.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + org.junit.jupiter @@ -125,11 +144,6 @@ test ${spring.boot.version} - - commons-io - commons-io - ${commonsio.version} - diff --git a/src/main/java/eu/europa/ec/dgc/gateway/connector/config/DgcGatewayConnectorConfigProperties.java b/src/main/java/eu/europa/ec/dgc/gateway/connector/config/DgcGatewayConnectorConfigProperties.java index c6f9ab8..8a356c8 100644 --- a/src/main/java/eu/europa/ec/dgc/gateway/connector/config/DgcGatewayConnectorConfigProperties.java +++ b/src/main/java/eu/europa/ec/dgc/gateway/connector/config/DgcGatewayConnectorConfigProperties.java @@ -109,7 +109,7 @@ public static class Proxy { /** * Enable HTTP Proxy. */ - private boolean enabled = false; + private boolean enabled; /** * Host Address of Proxy server (without protocol). diff --git a/src/main/java/eu/europa/ec/dgc/gateway/connector/config/DgcGatewayConnectorKeystore.java b/src/main/java/eu/europa/ec/dgc/gateway/connector/config/DgcGatewayConnectorKeystore.java index 5816ab7..165bb63 100644 --- a/src/main/java/eu/europa/ec/dgc/gateway/connector/config/DgcGatewayConnectorKeystore.java +++ b/src/main/java/eu/europa/ec/dgc/gateway/connector/config/DgcGatewayConnectorKeystore.java @@ -92,7 +92,7 @@ public KeyStore trustAnchorKeyStore() throws KeyStoreException, IOException, } private void loadKeyStore(KeyStore keyStore, String path, char[] password) - throws CertificateException, NoSuchAlgorithmException, IOException { + throws CertificateException, NoSuchAlgorithmException { try (InputStream fileStream = new FileInputStream(ResourceUtils.getFile(path))) { diff --git a/src/main/java/eu/europa/ec/dgc/generation/Base45Encoder.java b/src/main/java/eu/europa/ec/dgc/generation/Base45Encoder.java new file mode 100644 index 0000000..70c7198 --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/Base45Encoder.java @@ -0,0 +1,37 @@ +package eu.europa.ec.dgc.generation; + +/** + * Base45 Encoder. + */ +public class Base45Encoder { + private static final String ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"; + + private Base45Encoder() { + } + + /** + * Encode a byte array to Base45 String. + * + * @param bytes bytes to encode + * @return encoded string + */ + public static String encodeToString(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < bytes.length; i += 2) { + if (bytes.length - i > 1) { + int x = ((bytes[i] & 0xFF) << 8) + (bytes[i + 1] & 0xFF); + int e = x / (45 * 45); + int y = x % (45 * 45); + int d = y / 45; + int c = y % 45; + result.append(ALPHABET.charAt(c)).append(ALPHABET.charAt(d)).append(ALPHABET.charAt(e)); + } else { + int x = bytes[i] & 0xFF; + int d = x / 45; + int c = x % 45; + result.append(ALPHABET.charAt(c)).append(ALPHABET.charAt(d)); + } + } + return result.toString(); + } +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/CopyDigest.java b/src/main/java/eu/europa/ec/dgc/generation/CopyDigest.java new file mode 100644 index 0000000..a4c3bc8 --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/CopyDigest.java @@ -0,0 +1,98 @@ +package eu.europa.ec.dgc.generation; + +import java.io.ByteArrayOutputStream; +import org.bouncycastle.crypto.Digest; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.util.Arrays; +import org.springframework.stereotype.Service; + +@Service +public class CopyDigest implements Digest { + + private final OpenByteArrayOutputStream binaryOut = new OpenByteArrayOutputStream(); + private final SHA256Digest sha256Digest = new SHA256Digest(); + private boolean wasReset = false; + + public String getAlgorithmName() { + return "NULL"; + } + + public int getDigestSize() { + return 32; + } + + /** + * Updates the Message Digest with one byte. + * + * @param in byte to update. + */ + public void update(byte in) { + if (wasReset) { + sha256Digest.update(in); + } else { + binaryOut.write(in); + } + } + + /** + * Updates the Message Digest with a byte array. + * + * @param in byte array to insert + * @param offset Offset + * @param length length + */ + public void update(byte[] in, int offset, int length) { + if (wasReset) { + sha256Digest.update(in, offset, length); + } else { + binaryOut.write(in, offset, length); + } + } + + /** + * close the digest, producing the final digest value. The doFinal + * call leaves the digest reset. + * + * @param out the array the digest is to be copied into. + * @param offset the offset into the out array the digest is to start at. + * @return size of output + */ + public int doFinal(byte[] out, int offset) { + if (wasReset) { + return sha256Digest.doFinal(out, offset); + } else { + int size = binaryOut.size(); + binaryOut.copy(out, offset); + reset(); + return size; + } + } + + /** + * Resets the Message Digest. + */ + @Override + public void reset() { + if (wasReset) { + sha256Digest.reset(); + } else { + if (binaryOut.size() > 0) { + wasReset = true; + binaryOut.reset(); + } + } + } + + private static class OpenByteArrayOutputStream + extends ByteArrayOutputStream { + public synchronized void reset() { + super.reset(); + + Arrays.clear(buf); + } + + void copy(byte[] out, int outOff) { + System.arraycopy(buf, 0, out, outOff, this.size()); + } + } +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/DccBuilderBase.java b/src/main/java/eu/europa/ec/dgc/generation/DccBuilderBase.java new file mode 100644 index 0000000..d36da14 --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/DccBuilderBase.java @@ -0,0 +1,191 @@ +package eu.europa.ec.dgc.generation; + +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.EnumSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * abstract builder for all certificate types. + * @param the concrete builder + */ +public abstract class DccBuilderBase> { + + protected JsonNodeFactory jsonNodeFactory; + protected ObjectNode dccObject; + protected ObjectNode nameObject; + + private static final Pattern countryPattern = Pattern.compile("[A-Z]{1,10}"); + private static final Pattern standardNamePattern = Pattern.compile("^[A-Z<]*$"); + + private DateTimeFormatter dateFormat; + private DateTimeFormatter dayDateFormat; + + private enum RequiredFieldsBase { dob, fnt, co, is, ci } + + private EnumSet requiredNotSet = EnumSet.allOf(RequiredFieldsBase.class); + + /** + * constructor. + */ + public DccBuilderBase() { + dateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ"); + dayDateFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + jsonNodeFactory = JsonNodeFactory.instance; + dccObject = jsonNodeFactory.objectNode(); + nameObject = jsonNodeFactory.objectNode(); + + dccObject.set("version", jsonNodeFactory.textNode("1.0.0")); + dccObject.set("nam", nameObject); + } + + public abstract T getThis(); + + public abstract ObjectNode getValueHolder(); + + /** + * family name field. + * + * @param fn family name + * @return builder + */ + public T fn(String fn) { + assertNotNullMax("fn", fn, 50); + nameObject.set("fn", jsonNodeFactory.textNode(fn)); + return getThis(); + } + + /** + * given name. + * + * @param gn given name + * @return builder + */ + public T gn(String gn) { + assertNotNullMax("gn", gn, 50); + nameObject.set("gn", jsonNodeFactory.textNode(gn)); + return getThis(); + } + + /** + * standardized family name. + * + * @param fnt standardized family name + * @return builder + */ + public T fnt(String fnt) { + assertNotNullMaxPattern("fnt", fnt, 50, standardNamePattern); + requiredNotSet.remove(RequiredFieldsBase.fnt); + nameObject.set("fnt", jsonNodeFactory.textNode(fnt)); + return getThis(); + } + + /** + * standarized given name. + * + * @param gnt standardized given name + * @return builder + */ + public T gnt(String gnt) { + assertNotNullMaxPattern("gnt", gnt, 50, standardNamePattern); + nameObject.set("gnt", jsonNodeFactory.textNode(gnt)); + return getThis(); + } + + protected void validate() { + if (!requiredNotSet.isEmpty()) { + throw new IllegalStateException("not all required fields set " + requiredNotSet); + } + } + + /** + * buidl json string. + * + * @return json string + */ + public String toJsonString() { + validate(); + return dccObject.toString(); + } + + /** + * date of birth in iso format. + * + * @param birthday dob + * @return builder + */ + public T dob(LocalDate birthday) { + dccObject.set("dob", jsonNodeFactory.textNode(toIsoDate(birthday))); + requiredNotSet.remove(RequiredFieldsBase.dob); + return getThis(); + } + + /** + * country of test. + * @param co co + * @return builder + */ + public T country(String co) { + assertNotNullMaxPattern("co",co,0,countryPattern); + getValueHolder().set("co", jsonNodeFactory.textNode(co)); + requiredNotSet.remove(RequiredFieldsBase.co); + return getThis(); + } + + /** + * test issuer. + * @param is issuer + * @return builder + */ + public T certificateIssuer(String is) { + assertNotNullMax("is",is,50); + getValueHolder().set("is", jsonNodeFactory.textNode(is)); + requiredNotSet.remove(RequiredFieldsBase.is); + return getThis(); + } + + /** + * certificate identifier. + * @param dgci certificate identifier + * @return builder + */ + public T dgci(String dgci) { + assertNotNullMax("ci", dgci, 50); + getValueHolder().set("ci", jsonNodeFactory.textNode(dgci)); + requiredNotSet.remove(RequiredFieldsBase.ci); + return getThis(); + } + + protected String toIsoO8601(LocalDateTime dateTime) { + return dateTime.atZone(ZoneOffset.UTC).format(dateFormat); + } + + protected String toIsoDate(LocalDate date) { + return date.format(dayDateFormat); + } + + protected void assertNotNullMax(String description, String value, int maxLenght) { + if (value == null) { + throw new IllegalArgumentException("field " + description + " must not be null"); + } + if (maxLenght > 0 && value.length() > maxLenght) { + throw new IllegalArgumentException("field " + description + " has max length " + + maxLenght + " but was: " + value.length()); + } + } + + protected void assertNotNullMaxPattern(String description, String value, int maxLenght, Pattern pattern) { + assertNotNullMax(description, value, maxLenght); + Matcher matcher = pattern.matcher(value); + if (!matcher.matches()) { + throw new IllegalArgumentException("field: " + description + "value: " + + value + " do not match pattern: " + pattern); + } + } +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/DccRecoveryBuilder.java b/src/main/java/eu/europa/ec/dgc/generation/DccRecoveryBuilder.java new file mode 100644 index 0000000..d56a7ee --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/DccRecoveryBuilder.java @@ -0,0 +1,84 @@ +package eu.europa.ec.dgc.generation; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.EnumSet; + +public class DccRecoveryBuilder extends DccBuilderBase { + + private ObjectNode recoveryObject; + + private enum RequiredFields { fr, df, du } + + private EnumSet requiredNotSet = EnumSet.allOf(RequiredFields.class); + + /** + * the constructor. + */ + public DccRecoveryBuilder() { + super(); + + recoveryObject = jsonNodeFactory.objectNode(); + ArrayNode vaccinationArray = jsonNodeFactory.arrayNode(); + vaccinationArray.add(recoveryObject); + // disease-agent-targeted COVID-19 + // see https://github.com/ehn-digital-green-development/ehn-dgc-schema/blob/main/valuesets/disease-agent-targeted.json + recoveryObject.set("tg", jsonNodeFactory.textNode("840539006")); + dccObject.set("r", vaccinationArray); + } + + @Override + public DccRecoveryBuilder getThis() { + return this; + } + + @Override + public ObjectNode getValueHolder() { + return recoveryObject; + } + + protected void validate() { + super.validate(); + if (!requiredNotSet.isEmpty()) { + throw new IllegalStateException("not all required fields set " + requiredNotSet); + } + } + + /** + * first Day Positive Test. + * @param fr first Day Positive Test. + * @return builder + */ + public DccRecoveryBuilder firstDayPositiveTest(LocalDate fr) { + recoveryObject.set("fr", jsonNodeFactory.textNode(toIsoDate(fr))); + requiredNotSet.remove(RequiredFields.fr); + return this; + } + + /** + * certificate valid from. + * @param df valid from. + * @return builder + */ + public DccRecoveryBuilder certificateValidFrom(LocalDate df) { + recoveryObject.set("df", jsonNodeFactory.textNode(toIsoDate(df))); + requiredNotSet.remove(RequiredFields.df); + return this; + } + + /** + * certificate valid until. + * @param du valid until. + * @return builder + */ + public DccRecoveryBuilder certificateValidUnitl(LocalDate du) { + recoveryObject.set("du", jsonNodeFactory.textNode(toIsoDate(du))); + requiredNotSet.remove(RequiredFields.du); + return this; + } + +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/DccTestBuilder.java b/src/main/java/eu/europa/ec/dgc/generation/DccTestBuilder.java new file mode 100644 index 0000000..cdc1b2d --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/DccTestBuilder.java @@ -0,0 +1,105 @@ +package eu.europa.ec.dgc.generation; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.EnumSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Builder for DCC Test Json. + */ +public class DccTestBuilder extends DccBuilderBase { + private ObjectNode testObject; + + private enum RequiredFields { tt, sc, tr, tc } + + private EnumSet requiredNotSet = EnumSet.allOf(RequiredFields.class); + + /** + * the constructor. + */ + public DccTestBuilder() { + super(); + + testObject = jsonNodeFactory.objectNode(); + ArrayNode testArray = jsonNodeFactory.arrayNode(); + testArray.add(testObject); + // disease-agent-targeted COVID-19 + // see https://github.com/ehn-digital-green-development/ehn-dgc-schema/blob/main/valuesets/disease-agent-targeted.json + testObject.set("tg", jsonNodeFactory.textNode("840539006")); + dccObject.set("t", testArray); + } + + @Override + public DccTestBuilder getThis() { + return this; + } + + @Override + public ObjectNode getValueHolder() { + return testObject; + } + + /** + * test result. + * @param covidDetected covid detected + * @return builder + */ + public DccTestBuilder detected(boolean covidDetected) { + // https://github.com/ehn-digital-green-development/ehn-dgc-schema/blob/main/valuesets/test-result.json + testObject.set("tr", jsonNodeFactory.textNode(covidDetected ? "260373001" : "260415000")); + requiredNotSet.remove(RequiredFields.tr); + return this; + } + + /** + * test type. + * @param isRapidTest true if rapid + * @return builder + */ + public DccTestBuilder testTypeRapid(boolean isRapidTest) { + testObject.set("tt", jsonNodeFactory.textNode(isRapidTest ? "LP217198-3" : "LP6464-4")); + requiredNotSet.remove(RequiredFields.tt); + return this; + } + + + + /** + * testing centre. + * @param tc testing centre + * @return builder + */ + public DccTestBuilder testingCentre(String tc) { + testObject.set("tc", jsonNodeFactory.textNode(tc)); + assertNotNullMax("tc",tc,50); + requiredNotSet.remove(RequiredFields.tc); + return this; + } + + /** + * date time of sample collection. + * @param dateTime sc + * @return builder + */ + public DccTestBuilder sampleCollection(LocalDateTime dateTime) { + testObject.set("sc", jsonNodeFactory.textNode(toIsoO8601(dateTime))); + requiredNotSet.remove(RequiredFields.sc); + return this; + } + + + + protected void validate() { + super.validate(); + if (!requiredNotSet.isEmpty()) { + throw new IllegalStateException("not all required fields set " + requiredNotSet); + } + } + +} \ No newline at end of file diff --git a/src/main/java/eu/europa/ec/dgc/generation/DccVaccinationBuilder.java b/src/main/java/eu/europa/ec/dgc/generation/DccVaccinationBuilder.java new file mode 100644 index 0000000..91ad01f --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/DccVaccinationBuilder.java @@ -0,0 +1,123 @@ +package eu.europa.ec.dgc.generation; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.time.LocalDateTime; +import java.util.EnumSet; + +public class DccVaccinationBuilder extends DccBuilderBase { + + private ObjectNode vaccinationObject; + + private enum RequiredFields { vp, mp, ma, dn, sd, dt } + + private EnumSet requiredNotSet = EnumSet.allOf(RequiredFields.class); + + /** + * the constructor. + */ + public DccVaccinationBuilder() { + super(); + + vaccinationObject = jsonNodeFactory.objectNode(); + ArrayNode vaccinationArray = jsonNodeFactory.arrayNode(); + vaccinationArray.add(vaccinationObject); + // disease-agent-targeted COVID-19 + // see https://github.com/ehn-digital-green-development/ehn-dgc-schema/blob/main/valuesets/disease-agent-targeted.json + vaccinationObject.set("tg", jsonNodeFactory.textNode("840539006")); + dccObject.set("v", vaccinationArray); + } + + @Override + public DccVaccinationBuilder getThis() { + return this; + } + + @Override + public ObjectNode getValueHolder() { + return vaccinationObject; + } + + protected void validate() { + super.validate(); + if (!requiredNotSet.isEmpty()) { + throw new IllegalStateException("not all required fields set " + requiredNotSet); + } + } + + /** + * vaccine Or Prophylaxis. + * @param vp vaccineOrProphylaxis + * @return builder + */ + public DccVaccinationBuilder vaccineOrProphylaxis(String vp) { + /* TODO validate the vp or enum */ + vaccinationObject.set("vp", jsonNodeFactory.textNode(vp)); + requiredNotSet.remove(RequiredFields.vp); + return this; + } + + /** + * medical product. + * @param mp medical product + * @return builder + */ + public DccVaccinationBuilder medicalProduct(String mp) { + /* TODO validate the mp or enum */ + vaccinationObject.set("mp", jsonNodeFactory.textNode(mp)); + requiredNotSet.remove(RequiredFields.mp); + return this; + } + + /** + * marketing Authorization. + * @param ma marketingAuthorization + * @return builder + */ + public DccVaccinationBuilder marketingAuthorization(String ma) { + /* TODO validate the ma or enum */ + vaccinationObject.set("ma", jsonNodeFactory.textNode(ma)); + requiredNotSet.remove(RequiredFields.ma); + return this; + } + + /** + * dose number. + * @param dn dose number + * @return builder + */ + public DccVaccinationBuilder doseNumber(int dn) { + if (dn < 1 || dn > 9) { + throw new IllegalArgumentException("invalid range of dn (1-9)"); + } + vaccinationObject.set("dn", jsonNodeFactory.numberNode(dn)); + requiredNotSet.remove(RequiredFields.dn); + return this; + } + + /** + * total series of doses. + * @param sd total series of doses + * @return builder + */ + public DccVaccinationBuilder totalSeriesOfDoses(int sd) { + if (sd < 1 || sd > 9) { + throw new IllegalArgumentException("invalid range of dn (1-9)"); + } + vaccinationObject.set("sd", jsonNodeFactory.numberNode(sd)); + requiredNotSet.remove(RequiredFields.sd); + return this; + } + + /** + * date time of vaccination. + * @param dateTime sc + * @return builder + */ + public DccVaccinationBuilder dateOfVaccination(LocalDateTime dateTime) { + vaccinationObject.set("dt", jsonNodeFactory.textNode(toIsoO8601(dateTime))); + requiredNotSet.remove(RequiredFields.dt); + return this; + } + +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/DgcCryptedFinalizer.java b/src/main/java/eu/europa/ec/dgc/generation/DgcCryptedFinalizer.java new file mode 100644 index 0000000..191ef7a --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/DgcCryptedFinalizer.java @@ -0,0 +1,83 @@ +package eu.europa.ec.dgc.generation; + +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.spec.InvalidKeySpecException; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * build final dcc qr code from encode dcc data and signature. + */ +@Service +@Slf4j +public class DgcCryptedFinalizer { + + /** + * finalize dcc. + * + * @param encodedDccData dcc data + * @param dek encoded key + * @param privateKey private key + * @param signature dcc signature + * @return qr code of final dcc + */ + public String finalizeDcc(byte[] encodedDccData, byte[] dek, PrivateKey privateKey, byte[] signature) { + DgcGenerator dgcGenerator = new DgcGenerator(); + byte[] dgcData = new byte[0]; + try { + dgcData = decryptDccData(encodedDccData, dek, privateKey); + } catch (GeneralSecurityException e) { + log.error("Failed to finalize DCC: {}", e.getMessage()); + throw new IllegalStateException("can not decrypt dcc data"); + } + byte[] dgcCose = dgcGenerator.dgcSetCoseSignature(dgcData, signature); + return dgcGenerator.coseToQrCode(dgcCose); + } + + /** + * finalize dcc. + * @param encodedDccData dcc data + * @param dek encoded key + * @param privateKey private key + * @param partialDcc cose with signature and key + * @return qr code of final dcc + */ + public String finalizePartialDcc(byte[] encodedDccData, byte[] dek, PrivateKey privateKey, byte[] partialDcc) { + DgcGenerator dgcGenerator = new DgcGenerator(); + byte[] dgcData = new byte[0]; + try { + dgcData = decryptDccData(encodedDccData, dek, privateKey); + } catch (GeneralSecurityException e) { + log.error("Failed to finalize DCC: {}", e.getMessage()); + throw new IllegalStateException("can not decrypt dcc data"); + } + byte[] dgcCose = dgcGenerator.dgcSetCosePartial(dgcData, partialDcc); + return dgcGenerator.coseToQrCode(dgcCose); + } + + private byte[] decryptDccData(byte[] encodedDccData, byte[] dek, PrivateKey privateKey) + throws java.security.GeneralSecurityException { + // decrypt RSA key + Cipher keyCipher = Cipher.getInstance(DgcCryptedPublisher.KEY_CIPHER); + keyCipher.init(Cipher.DECRYPT_MODE, privateKey); + byte[] rsaKey = keyCipher.doFinal(dek); + + byte[] iv = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + IvParameterSpec ivspec = new IvParameterSpec(iv); + Cipher cipher = Cipher.getInstance(DgcCryptedPublisher.DATA_CIPHER); + + SecretKeySpec secretKeySpec = new SecretKeySpec(rsaKey, 0, rsaKey.length, "AES"); + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivspec); + return cipher.doFinal(encodedDccData); + } +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/DgcCryptedPublisher.java b/src/main/java/eu/europa/ec/dgc/generation/DgcCryptedPublisher.java new file mode 100644 index 0000000..46e6a22 --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/DgcCryptedPublisher.java @@ -0,0 +1,78 @@ +package eu.europa.ec.dgc.generation; + +import eu.europa.ec.dgc.generation.dto.DgcData; +import eu.europa.ec.dgc.generation.dto.DgcInitData; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.KeyGenerator; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.IvParameterSpec; +import org.springframework.stereotype.Service; + +@Service +public class DgcCryptedPublisher { + + public static final String KEY_CIPHER = "RSA/ECB/PKCS1Padding"; + public static final String DATA_CIPHER = "AES/CBC/PKCS5Padding"; + + private final DgcGenerator dgcGenerator = new DgcGenerator(); + + /** + * Create dgc data. + * + * @param dgcInitData init params + * @param dgcPayloadJson dcc payload + * @param publicKey pubic key + * @return data + */ + public DgcData createDgc(DgcInitData dgcInitData, String dgcPayloadJson, PublicKey publicKey) { + byte[] edgcCbor = dgcGenerator.genDgcCbor(dgcPayloadJson, dgcInitData.getIssuerCode(), + dgcInitData.getIssuedAt(), dgcInitData.getExpriation()); + byte[] edgcCoseUnsigned = + dgcGenerator.genCoseUnsigned(edgcCbor, dgcInitData.getKeyId(), dgcInitData.getAlgId()); + byte[] edgcHash = dgcGenerator.computeCoseSignHash(edgcCoseUnsigned); + + DgcData dgcData = new DgcData(); + dgcData.setHash(edgcHash); + dgcData.setDccData(edgcCoseUnsigned); + + try { + encryptData(dgcData, edgcCoseUnsigned, publicKey); + } catch (NoSuchAlgorithmException | NoSuchPaddingException + | InvalidKeyException | InvalidAlgorithmParameterException + | IllegalBlockSizeException | BadPaddingException e) { + throw new IllegalArgumentException(e); + } + + return dgcData; + } + + private void encryptData(DgcData dgcData, byte[] edgcCoseUnsigned, PublicKey publicKey) throws + NoSuchAlgorithmException, NoSuchPaddingException, InvalidKeyException, + InvalidAlgorithmParameterException, IllegalBlockSizeException, BadPaddingException { + KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + keyGen.init(256); // for example + SecretKey secretKey = keyGen.generateKey(); + + // TODO set iv to something special + byte[] iv = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + IvParameterSpec ivspec = new IvParameterSpec(iv); + Cipher cipher = Cipher.getInstance(DATA_CIPHER); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivspec); + byte[] edgcDataEncrpyted = cipher.doFinal(edgcCoseUnsigned); + + dgcData.setDataEncrypted(edgcDataEncrpyted); + + // encrypt RSA key + Cipher keyCipher = Cipher.getInstance(KEY_CIPHER); + keyCipher.init(Cipher.ENCRYPT_MODE, publicKey); + byte[] secretKeyBytes = secretKey.getEncoded(); + dgcData.setDek(keyCipher.doFinal(secretKeyBytes)); + } +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/DgcGenerator.java b/src/main/java/eu/europa/ec/dgc/generation/DgcGenerator.java new file mode 100644 index 0000000..0241322 --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/DgcGenerator.java @@ -0,0 +1,165 @@ +package eu.europa.ec.dgc.generation; + +import com.upokecenter.cbor.CBORObject; +import com.upokecenter.cbor.CBORType; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.zip.Deflater; +import java.util.zip.DeflaterInputStream; +import org.springframework.stereotype.Service; + +/** + * Dgc Generator util. + * It can generate EDGC cose structure, hash for signing, + * copy the signature into cose and also generate final QR Code as string. + */ +@Service +public class DgcGenerator { + /** + * Generate CBOR EDGC payload for COSE. + * The json string need to be valid according to specification + * https://github.com/ehn-digital-green-development/hcert-spec + * no json structure validation is done here + * + * @param edgcJson edgc payload as json string + * @param countryCode for example DE + * @param issuedAt unix time in sec + * @param expirationSec unix time in sec + * @return CBOR bytes + */ + public byte[] genDgcCbor(String edgcJson, String countryCode, long issuedAt, long expirationSec) { + CBORObject map = CBORObject.NewMap(); + if (countryCode == null || countryCode.length() == 0) { + throw new IllegalArgumentException("dcc issuer is null or empty"); + } + map.set(CBORObject.FromObject(1), CBORObject.FromObject(countryCode)); + map.set(CBORObject.FromObject(6), CBORObject.FromObject(issuedAt)); + map.set(CBORObject.FromObject(4), CBORObject.FromObject(expirationSec)); + CBORObject hcertVersion = CBORObject.NewMap(); + CBORObject hcert = CBORObject.FromJSONString(edgcJson); + hcertVersion.set(CBORObject.FromObject(1), hcert); + map.set(CBORObject.FromObject(-260), hcertVersion); + return map.EncodeToBytes(); + } + + /** + * Generate COSE unsigned structure with payload. + * + * @param payload it should be CBOR data + * @param keyId for protected data or null if not to set + * @param algId for protected data for example -7 (means EC) or 0 if not to set + * @return cose bytes + */ + public byte[] genCoseUnsigned(byte[] payload, byte[] keyId, int algId) { + CBORObject protectedHeader = CBORObject.NewMap(); + if (algId != 0) { + protectedHeader.set(CBORObject.FromObject(1), CBORObject.FromObject(algId)); + } + if (keyId != null) { + protectedHeader.set(CBORObject.FromObject(4), CBORObject.FromObject(keyId)); + } + byte[] protectedHeaderBytes = protectedHeader.EncodeToBytes(); + + CBORObject coseObject = CBORObject.NewArray(); + coseObject.Add(protectedHeaderBytes); + CBORObject unprotectedHeader = CBORObject.NewMap(); + coseObject.Add(unprotectedHeader); + coseObject.Add(CBORObject.FromObject(payload)); + byte[] sigDummy = new byte[0]; + coseObject.Add(CBORObject.FromObject(sigDummy)); + return CBORObject.FromObjectAndTag(coseObject, 18).EncodeToBytes(); + } + + /** + * Set Signature in COSE data. + * + * @param coseData bytes + * @param signature cose signature + * @return cose bytes with signature + */ + public byte[] dgcSetCoseSignature(byte[] coseData, byte[] signature) { + CBORObject cborObject = CBORObject.DecodeFromBytes(coseData); + if (cborObject.getType() == CBORType.Array && cborObject.getValues().size() == 4) { + cborObject.set(3, CBORObject.FromObject(signature)); + } else { + throw new IllegalArgumentException("seems not to be cose"); + } + return cborObject.EncodeToBytes(); + } + + /** + * Set signature and unprotected header from partialDcc into unsigned cose dcc. + * + * @param coseData unsigned cose dcc + * @param partialDcc cose with signature and unprotected header + * @return signed cose dcc + */ + public byte[] dgcSetCosePartial(byte[] coseData, byte[] partialDcc) { + CBORObject partialCose = CBORObject.DecodeFromBytes(partialDcc); + if (partialCose.getType() != CBORType.Array || partialCose.getValues().size() < 3) { + throw new IllegalArgumentException("partial dcc is not cbor array"); + } + CBORObject cborObject = CBORObject.DecodeFromBytes(coseData); + if (cborObject.getType() == CBORType.Array && cborObject.getValues().size() == 4) { + // set signature + cborObject.set(3, partialCose.get(3)); + } else { + throw new IllegalArgumentException("seems not to be cose"); + } + // copy unprotected header + CBORObject unprotectedHeader = partialCose.get(1); + if (unprotectedHeader.getType() != CBORType.Map) { + throw new IllegalArgumentException("unprotected header in partial dcc is not cbor map"); + } + for (CBORObject key : unprotectedHeader.getKeys()) { + CBORObject value = unprotectedHeader.get(key); + cborObject.get(1).set(key, value); + } + return cborObject.EncodeToBytes(); + } + + /** + * convert cose bytes to qr code data. + * + * @param cose signed edgc data + * @return qr code data + */ + public String coseToQrCode(byte[] cose) { + ByteArrayInputStream bis = new ByteArrayInputStream(cose); + DeflaterInputStream compessedInput = new DeflaterInputStream(bis, new Deflater(9)); + byte[] coseCompressed; + try { + coseCompressed = compessedInput.readAllBytes(); + } catch (IOException e) { + throw new IllegalArgumentException(e); + } + String coded = Base45Encoder.encodeToString(coseCompressed); + return "HC1:" + coded; + } + + /** + * comute hash from cose data. + * + * @param coseMessage cose message + * @return hash bytes + */ + public byte[] computeCoseSignHash(byte[] coseMessage) { + try { + CBORObject coseForSign = CBORObject.NewArray(); + CBORObject cborCose = CBORObject.DecodeFromBytes(coseMessage); + if (cborCose.getType() == CBORType.Array) { + coseForSign.Add(CBORObject.FromObject("Signature1")); + coseForSign.Add(cborCose.get(0).GetByteString()); + coseForSign.Add(new byte[0]); + coseForSign.Add(cborCose.get(2).GetByteString()); + } + byte[] coseForSignBytes = coseForSign.EncodeToBytes(); + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return digest.digest(coseForSignBytes); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/DgcSigner.java b/src/main/java/eu/europa/ec/dgc/generation/DgcSigner.java new file mode 100644 index 0000000..044e5f0 --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/DgcSigner.java @@ -0,0 +1,130 @@ +package eu.europa.ec.dgc.generation; + +import com.upokecenter.cbor.CBORObject; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.interfaces.RSAPrivateCrtKey; +import java.util.Arrays; +import org.bouncycastle.crypto.CryptoException; +import org.bouncycastle.crypto.Digest; +import org.bouncycastle.crypto.digests.SHA256Digest; +import org.bouncycastle.crypto.engines.RSABlindedEngine; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.crypto.params.ECPrivateKeyParameters; +import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters; +import org.bouncycastle.crypto.signers.ECDSASigner; +import org.bouncycastle.crypto.signers.PSSSigner; +import org.bouncycastle.jcajce.provider.asymmetric.util.EC5Util; +import org.bouncycastle.jce.spec.ECParameterSpec; +import org.springframework.stereotype.Service; + +/** + * The signer of cose message. It takes only hash as imput and does not need + * to know payload of cose data + */ +@Service +public class DgcSigner { + + /** + * sign hash. (encode hash) to signature. + * + * @param hashBytes computed cose hash + * @param privateKey can be EC or RSS + * @return signature + */ + public byte[] signHash(byte[] hashBytes, PrivateKey privateKey) { + byte[] signature; + try { + if (privateKey instanceof RSAPrivateCrtKey) { + signature = signRsapss(hashBytes, privateKey); + } else { + signature = signEc(hashBytes, privateKey); + } + } catch (CryptoException e) { + throw new IllegalArgumentException("error during signing ", e); + } + return signature; + } + + /** + * sign hash and build partial cose dcc containing key in unprotected header and signature. + * This variant can be user together with @link {@link DgcGenerator#dgcSetCosePartial}. + * @param hashBytes hash to sign + * @param privateKey private key + * @param keyId keyId bytes + * @return cose container but only with signature and unprotected header with keyId + */ + public byte[] signPartialDcc(byte[] hashBytes, PrivateKey privateKey, byte[] keyId) { + CBORObject protectedHeader = CBORObject.NewMap(); + byte[] protectedHeaderBytes = protectedHeader.EncodeToBytes(); + + CBORObject coseObject = CBORObject.NewArray(); + coseObject.Add(protectedHeaderBytes); + CBORObject unprotectedHeader = CBORObject.NewMap(); + unprotectedHeader.Add(CBORObject.FromObject(4),CBORObject.FromObject(keyId)); + coseObject.Add(unprotectedHeader); + byte[] contentDummy = new byte[0]; + coseObject.Add(CBORObject.FromObject(contentDummy)); + + byte[] signature = signHash(hashBytes, privateKey); + coseObject.Add(CBORObject.FromObject(signature)); + return CBORObject.FromObjectAndTag(coseObject, 18).EncodeToBytes(); + } + + /** + * keyId needed for cose header data. + * + * @param certificate certificate + * @return keyId bytes + */ + public byte[] keyId(Certificate certificate) { + try { + byte[] encoderCert = certificate.getEncoded(); + byte[] hash = MessageDigest.getInstance("SHA-256").digest(encoderCert); + return Arrays.copyOfRange(hash, 0, 8); + } catch (CertificateEncodingException | NoSuchAlgorithmException e) { + throw new IllegalArgumentException("can not gen keyid", e); + } + } + + private byte[] signRsapss(byte[] hashBytes, PrivateKey privateKey) throws CryptoException { + Digest contentDigest = new CopyDigest(); + Digest mgfDigest = new SHA256Digest(); + RSAPrivateCrtKey k = (RSAPrivateCrtKey) privateKey; + RSAPrivateCrtKeyParameters keyparam = new RSAPrivateCrtKeyParameters(k.getModulus(), + k.getPublicExponent(), k.getPrivateExponent(), + k.getPrimeP(), k.getPrimeQ(), k.getPrimeExponentP(), k.getPrimeExponentQ(), k.getCrtCoefficient()); + RSABlindedEngine rsaBlindedEngine = new RSABlindedEngine(); + rsaBlindedEngine.init(true, keyparam); + PSSSigner pssSigner = new PSSSigner(rsaBlindedEngine, contentDigest, mgfDigest, 32, (byte) (-68)); + pssSigner.init(true, keyparam); + pssSigner.update(hashBytes, 0, hashBytes.length); + return pssSigner.generateSignature(); + } + + private byte[] signEc(byte[] hash, PrivateKey privateKey) { + java.security.interfaces.ECPrivateKey privKey = (java.security.interfaces.ECPrivateKey) privateKey; + ECParameterSpec s = EC5Util.convertSpec(privKey.getParams()); + ECPrivateKeyParameters keyparam = new ECPrivateKeyParameters( + privKey.getS(), + new ECDomainParameters(s.getCurve(), s.getG(), s.getN(), s.getH(), s.getSeed())); + ECDSASigner ecdsaSigner = new ECDSASigner(); + ecdsaSigner.init(true, keyparam); + BigInteger[] result3BI = ecdsaSigner.generateSignature(hash); + byte[] rvarArr = result3BI[0].toByteArray(); + byte[] svarArr = result3BI[1].toByteArray(); + // we need to convert it to 2*32 bytes array. This can 33 with leading 0 or shorter so padding is needed + byte[] sig = new byte[64]; + System.arraycopy(rvarArr, rvarArr.length == 33 ? 1 : 0, sig, + Math.max(0, 32 - rvarArr.length), Math.min(32, rvarArr.length)); + System.arraycopy(svarArr, svarArr.length == 33 ? 1 : 0, sig, + 32 + Math.max(0, 32 - svarArr.length), Math.min(32, svarArr.length)); + + return sig; + } + +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/DgciGenerator.java b/src/main/java/eu/europa/ec/dgc/generation/DgciGenerator.java new file mode 100644 index 0000000..fc9b3c9 --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/DgciGenerator.java @@ -0,0 +1,93 @@ +package eu.europa.ec.dgc.generation; + +import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.util.UUID; + +public class DgciGenerator { + + private static final String CODE_POINTS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/:"; + private final String dgciPrefix; + + public DgciGenerator(String dgciPrefix) { + checkPrefix(dgciPrefix); + this.dgciPrefix = dgciPrefix; + } + + /** + * Check if dgci prefix contains character suitable for checksum calculation. + */ + public void checkPrefix(String dgciPrefix) { + if (dgciPrefix != null) { + for (int i = 0; i < dgciPrefix.length(); i++) { + if (CODE_POINTS.indexOf(dgciPrefix.charAt(i)) < 0) { + throw new IllegalArgumentException("configured DGCI prefix '" + + dgciPrefix + "' contains invalid character '" + + dgciPrefix.charAt(i) + "' only following are supported " + CODE_POINTS); + } + } + } + } + + /** + * Generates a new DGCI. + * + * @return DGCI as String + */ + public String newDgci() { + StringBuilder sb = new StringBuilder(); + sb.append(dgciPrefix).append(':'); + sb.append(encodeDgci(UUID.randomUUID())); + sb.append(generateCheckCharacter(sb.toString())); + return sb.toString(); + } + + private String encodeDgci(UUID uuid) { + ByteBuffer bb = ByteBuffer.wrap(new byte[16]); + bb.putLong(uuid.getMostSignificantBits()); + bb.putLong(uuid.getLeastSignificantBits()); + BigInteger bint = new BigInteger(1, bb.array()); + int radix = 10 + ('Z' - 'A'); + return bint.toString(radix).toUpperCase(); + } + + // see https://en.wikipedia.org/wiki/Luhn_mod_N_algorithm + private char generateCheckCharacter(String input) { + int factor = 2; + int sum = 0; + int n = CODE_POINTS.length(); + + // Starting from the right and working leftwards is easier since + // the initial "factor" will always be "2". + for (int i = input.length() - 1; i >= 0; i--) { + int codePoint = codePointFromCharacter(input.charAt(i)); + int addend = factor * codePoint; + + // Alternate the "factor" that each "codePoint" is multiplied by + factor = (factor == 2) ? 1 : 2; + + // Sum the digits of the "addend" as expressed in base "n" + addend = (addend / n) + (addend % n); + sum += addend; + } + + // Calculate the number that must be added to the "sum" + // to make it divisible by "n". + int remainder = sum % n; + int checkCodePoint = (n - remainder) % n; + + return characterFromCodePoint(checkCodePoint); + } + + private char characterFromCodePoint(int checkCodePoint) { + return CODE_POINTS.charAt(checkCodePoint); + } + + private int codePointFromCharacter(char charAt) { + int codePoint = CODE_POINTS.indexOf(charAt); + if (codePoint < 0) { + throw new IllegalArgumentException("unsupported character for checksum: " + charAt); + } + return codePoint; + } +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/dto/DgcData.java b/src/main/java/eu/europa/ec/dgc/generation/dto/DgcData.java new file mode 100644 index 0000000..8d37800 --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/dto/DgcData.java @@ -0,0 +1,41 @@ +package eu.europa.ec.dgc.generation.dto; + +public class DgcData { + + private byte[] dek; + private byte[] dataEncrypted; + private byte[] hash; + private byte[] dccData; + + public byte[] getDek() { + return dek; + } + + public void setDek(byte[] dek) { + this.dek = dek; + } + + public byte[] getDataEncrypted() { + return dataEncrypted; + } + + public void setDataEncrypted(byte[] dataEncrypted) { + this.dataEncrypted = dataEncrypted; + } + + public byte[] getHash() { + return hash; + } + + public void setHash(byte[] hash) { + this.hash = hash; + } + + public byte[] getDccData() { + return dccData; + } + + public void setDccData(byte[] dccData) { + this.dccData = dccData; + } +} diff --git a/src/main/java/eu/europa/ec/dgc/generation/dto/DgcInitData.java b/src/main/java/eu/europa/ec/dgc/generation/dto/DgcInitData.java new file mode 100644 index 0000000..20944a7 --- /dev/null +++ b/src/main/java/eu/europa/ec/dgc/generation/dto/DgcInitData.java @@ -0,0 +1,50 @@ +package eu.europa.ec.dgc.generation.dto; + +public class DgcInitData { + + private String issuerCode; + private long issuedAt; + private long expriation; + private int algId; + private byte[] keyId; + + public String getIssuerCode() { + return issuerCode; + } + + public void setIssuerCode(String issuerCode) { + this.issuerCode = issuerCode; + } + + public long getIssuedAt() { + return issuedAt; + } + + public void setIssuedAt(long issuedAt) { + this.issuedAt = issuedAt; + } + + public long getExpriation() { + return expriation; + } + + public void setExpriation(long expriation) { + this.expriation = expriation; + } + + public int getAlgId() { + return algId; + } + + public void setAlgId(int algId) { + this.algId = algId; + } + + public byte[] getKeyId() { + return keyId; + } + + public void setKeyId(byte[] keyId) { + this.keyId = keyId; + } +} diff --git a/src/main/resources/dgcg_tst_digit.p12 b/src/main/resources/dgcg_tst_digit.p12 new file mode 100644 index 0000000..4555b0e Binary files /dev/null and b/src/main/resources/dgcg_tst_digit.p12 differ diff --git a/src/test/java/eu/europa/ec/dgc/gateway/connector/UploadConnectorTest.java b/src/test/java/eu/europa/ec/dgc/gateway/connector/UploadConnectorTest.java index c22f46a..53e30b9 100644 --- a/src/test/java/eu/europa/ec/dgc/gateway/connector/UploadConnectorTest.java +++ b/src/test/java/eu/europa/ec/dgc/gateway/connector/UploadConnectorTest.java @@ -28,7 +28,6 @@ import feign.FeignException; import feign.Request; import feign.RequestTemplate; -import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; diff --git a/src/test/java/eu/europa/ec/dgc/generation/Base45EncoderTest.java b/src/test/java/eu/europa/ec/dgc/generation/Base45EncoderTest.java new file mode 100644 index 0000000..801f68a --- /dev/null +++ b/src/test/java/eu/europa/ec/dgc/generation/Base45EncoderTest.java @@ -0,0 +1,16 @@ +package eu.europa.ec.dgc.generation; + +import java.nio.charset.StandardCharsets; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; + +class Base45EncoderTest { + + @Test + void encodeTest() { + assertEquals("7WE QE",Base45Encoder.encodeToString("test".getBytes(StandardCharsets.UTF_8))); + + byte[] bytes = new byte[] { 0, 2, -2, 30, -12, 23, -23, -40}; + assertEquals("200T5WR%UEPT",Base45Encoder.encodeToString(bytes)); + } +} \ No newline at end of file diff --git a/src/test/java/eu/europa/ec/dgc/generation/DccRecoveryBuilderTest.java b/src/test/java/eu/europa/ec/dgc/generation/DccRecoveryBuilderTest.java new file mode 100644 index 0000000..f9e0927 --- /dev/null +++ b/src/test/java/eu/europa/ec/dgc/generation/DccRecoveryBuilderTest.java @@ -0,0 +1,24 @@ +package eu.europa.ec.dgc.generation; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DccRecoveryBuilderTest { + @Test + void genTestJson() { + DccRecoveryBuilder dccRecoveryBuilder = new DccRecoveryBuilder(); + dccRecoveryBuilder.gn("Artur").fn("Trzewik").gnt("ARTUR").fnt("TRZEWIK").dob(LocalDate.parse("1973-01-01")); + dccRecoveryBuilder.dgci("URN:UVCI:01:OS:B5921A35D6A0D696421B3E2462178297I") + .country("DE") + .certificateIssuer("Dr Who") + .firstDayPositiveTest(LocalDate.now()) + .certificateValidFrom(LocalDate.now()) + .certificateValidUnitl(LocalDate.now()); + String jsonString = dccRecoveryBuilder.toJsonString(); + assertNotNull(jsonString); + System.out.println(jsonString); + } +} \ No newline at end of file diff --git a/src/test/java/eu/europa/ec/dgc/generation/DccTestBuilderTest.java b/src/test/java/eu/europa/ec/dgc/generation/DccTestBuilderTest.java new file mode 100644 index 0000000..03c4492 --- /dev/null +++ b/src/test/java/eu/europa/ec/dgc/generation/DccTestBuilderTest.java @@ -0,0 +1,44 @@ +package eu.europa.ec.dgc.generation; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Assertions; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import org.junit.jupiter.api.Test; + +class DccTestBuilderTest { + @Test + void requiredFieldsFormat() throws Exception { + DccTestBuilder dccTestBuilder = new DccTestBuilder(); + dccTestBuilder.fn("Tester"); + dccTestBuilder.fnt("TESTER"); + Assertions.assertThrows(IllegalStateException.class, () -> { + dccTestBuilder.toJsonString(); + }); + } + + @Test + void patternMatch() { + DccTestBuilder dccTestBuilder = new DccTestBuilder(); + Assertions.assertThrows(IllegalArgumentException.class, () -> { + dccTestBuilder.fnt("tester"); + }); + } + + @Test + void genTestJson() { + DccTestBuilder dccTestBuilder = new DccTestBuilder(); + dccTestBuilder.gn("Artur").fn("Trzewik").gnt("ARTUR").fnt("TRZEWIK").dob(LocalDate.parse("1973-01-01")); + dccTestBuilder.detected(false) + .dgci("URN:UVCI:01:OS:B5921A35D6A0D696421B3E2462178297I") + .country("DE") + .testTypeRapid(true) + .testingCentre("Hochdahl") + .certificateIssuer("Dr Who") + .sampleCollection(LocalDateTime.now()); + String jsonString = dccTestBuilder.toJsonString(); + assertNotNull(jsonString); + System.out.println(jsonString); + } + +} \ No newline at end of file diff --git a/src/test/java/eu/europa/ec/dgc/generation/DccVaccinationBuilderTest.java b/src/test/java/eu/europa/ec/dgc/generation/DccVaccinationBuilderTest.java new file mode 100644 index 0000000..8ecd400 --- /dev/null +++ b/src/test/java/eu/europa/ec/dgc/generation/DccVaccinationBuilderTest.java @@ -0,0 +1,27 @@ +package eu.europa.ec.dgc.generation; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class DccVaccinationBuilderTest { + @Test + void genTestJson() { + DccVaccinationBuilder dccVaccinationBuilder = new DccVaccinationBuilder(); + dccVaccinationBuilder.gn("Artur").fn("Trzewik").gnt("ARTUR").fnt("TRZEWIK").dob(LocalDate.parse("1973-01-01")); + dccVaccinationBuilder.dgci("URN:UVCI:01:OS:B5921A35D6A0D696421B3E2462178297I") + .country("DE") + .certificateIssuer("Dr Who") + .doseNumber(1) + .totalSeriesOfDoses(2) + .dateOfVaccination(LocalDateTime.now()) + .vaccineOrProphylaxis("1119349007") + .medicalProduct("EU/1/20/1507") + .marketingAuthorization("ORG-100001699"); + String jsonString = dccVaccinationBuilder.toJsonString(); + assertNotNull(jsonString); + System.out.println(jsonString); + } +} \ No newline at end of file diff --git a/src/test/java/eu/europa/ec/dgc/generation/DgcCryptedPublisherTest.java b/src/test/java/eu/europa/ec/dgc/generation/DgcCryptedPublisherTest.java new file mode 100644 index 0000000..5a2be65 --- /dev/null +++ b/src/test/java/eu/europa/ec/dgc/generation/DgcCryptedPublisherTest.java @@ -0,0 +1,135 @@ +package eu.europa.ec.dgc.generation; + +import eu.europa.ec.dgc.generation.dto.DgcData; +import eu.europa.ec.dgc.generation.dto.DgcInitData; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.time.Duration; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import javax.crypto.Cipher; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DgcCryptedPublisherTest { + KeyHelper keyHelper; + + @BeforeEach + void setup() throws Exception { + keyHelper = new KeyHelper(); + } + + @Test + void getEncodedDGCData() throws Exception { + DgcSigner dgcSigner = new DgcSigner(); + DgcCryptedPublisher dgcCryptedPublisher = new DgcCryptedPublisher(); + + String edgcJson = DgcSignerTest.genSampleJson(); + String countryCode = "DE"; + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime expiration = now.plus(Duration.of(365, ChronoUnit.DAYS)); + long issuedAt = now.toInstant().getEpochSecond(); + long expirationSec = expiration.toInstant().getEpochSecond(); + byte[] keyId = dgcSigner.keyId(keyHelper.getCert()); + // We assume that it is EC Key + int algId = -7; + + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(3072); + KeyPair keyPair = keyPairGen.generateKeyPair(); + + // Test coding of public key + // Base64-kodierte RSA-3072 Public Key in x.509 Format (ohne PEM Header/Footer). Immer 564 Zeichen (als Base64-Darstellung). + String publicKeyBase64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + assertEquals(564,publicKeyBase64.length()); + + DgcInitData dgcInitData = new DgcInitData(); + dgcInitData.setExpriation(expirationSec); + dgcInitData.setIssuedAt(issuedAt); + dgcInitData.setIssuerCode(countryCode); + dgcInitData.setKeyId(keyId); + dgcInitData.setAlgId(-7); + DgcData dgcData = dgcCryptedPublisher.createDgc(dgcInitData, edgcJson, keyPair.getPublic()); + + // Base64-kodierte und mit dem RSA Public Key verschlüsselter DEK. Der DEK selbst muss 32 Bytes haben (für AES-256). + // Der verschlüsselte DEK hat 384 Bytes und die base64-kodierte Darstellung entsprechend 512 Zeichen. + assertEquals(384,dgcData.getDek().length); + String dekBase64 = Base64.getEncoder().encodeToString(dgcData.getDek()); + assertEquals(512,dekBase64.length()); + + byte[] signature = dgcSigner.signHash(dgcData.getHash(),keyHelper.getPrivateKey()); + + DgcCryptedFinalizer dgcCryptedFinalizer = new DgcCryptedFinalizer(); + String edgcQRCode = dgcCryptedFinalizer.finalizeDcc(dgcData.getDataEncrypted(), dgcData.getDek(), keyPair.getPrivate(), signature); + System.out.println(edgcQRCode); + } + + @Test + void dccWithPartialDCCSigning() throws Exception { + DgcSigner dgcSigner = new DgcSigner(); + DgcCryptedPublisher dgcCryptedPublisher = new DgcCryptedPublisher(); + + String edgcJson = DgcSignerTest.genSampleJson(); + String countryCode = "DE"; + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime expiration = now.plus(Duration.of(365, ChronoUnit.DAYS)); + long issuedAt = now.toInstant().getEpochSecond(); + long expirationSec = expiration.toInstant().getEpochSecond(); + byte[] keyId = dgcSigner.keyId(keyHelper.getCert()); + // We assume that it is EC Key + int algId = -7; + + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(3072); + KeyPair keyPair = keyPairGen.generateKeyPair(); + + // Test coding of public key + // Base64-kodierte RSA-3072 Public Key in x.509 Format (ohne PEM Header/Footer). Immer 564 Zeichen (als Base64-Darstellung). + String publicKeyBase64 = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()); + assertEquals(564,publicKeyBase64.length()); + + DgcInitData dgcInitData = new DgcInitData(); + dgcInitData.setExpriation(expirationSec); + dgcInitData.setIssuedAt(issuedAt); + dgcInitData.setIssuerCode(countryCode); + // do not set keyId here, because is it unknown for dcc creator + dgcInitData.setKeyId(null); + dgcInitData.setAlgId(-7); + DgcData dgcData = dgcCryptedPublisher.createDgc(dgcInitData, edgcJson, keyPair.getPublic()); + + // Base64-kodierte und mit dem RSA Public Key verschlüsselter DEK. Der DEK selbst muss 32 Bytes haben (für AES-256). + // Der verschlüsselte DEK hat 384 Bytes und die base64-kodierte Darstellung entsprechend 512 Zeichen. + assertEquals(384,dgcData.getDek().length); + String dekBase64 = Base64.getEncoder().encodeToString(dgcData.getDek()); + assertEquals(512,dekBase64.length()); + + byte[] partialDcc = dgcSigner.signPartialDcc(dgcData.getHash(),keyHelper.getPrivateKey(),keyId); + + DgcCryptedFinalizer dgcCryptedFinalizer = new DgcCryptedFinalizer(); + String edgcQRCode = dgcCryptedFinalizer.finalizePartialDcc(dgcData.getDataEncrypted(), dgcData.getDek(), keyPair.getPrivate(), partialDcc); + System.out.println(edgcQRCode); + } + + @Test + void rsaCrypt() throws Exception { + KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); + keyPairGen.initialize(2048); + KeyPair keyPair = keyPairGen.generateKeyPair(); + + byte[] testData = "Test".getBytes(StandardCharsets.UTF_8); + + Cipher cipher = Cipher.getInstance(DgcCryptedPublisher.KEY_CIPHER); + cipher.init(Cipher.ENCRYPT_MODE, keyPair.getPublic()); + byte[] encrypted = cipher.doFinal(testData); + + Cipher cipherDecrypt = Cipher.getInstance(DgcCryptedPublisher.KEY_CIPHER); + cipherDecrypt.init(Cipher.DECRYPT_MODE, keyPair.getPrivate()); + byte[] decrypted = cipherDecrypt.doFinal(encrypted); + + assertArrayEquals(testData,decrypted); + } +} \ No newline at end of file diff --git a/src/test/java/eu/europa/ec/dgc/generation/DgcSignerTest.java b/src/test/java/eu/europa/ec/dgc/generation/DgcSignerTest.java new file mode 100644 index 0000000..b5bb1aa --- /dev/null +++ b/src/test/java/eu/europa/ec/dgc/generation/DgcSignerTest.java @@ -0,0 +1,63 @@ +package eu.europa.ec.dgc.generation; + +import java.io.IOException; +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class DgcSignerTest { + KeyHelper keyHelper; + + @BeforeEach + void setup() throws Exception { + keyHelper = new KeyHelper(); + } + + public static String genSampleJson() { + DccTestBuilder dccTestBuilder = new DccTestBuilder(); + dccTestBuilder.gn("Artur").fn("Trzewik").gnt("ARTUR").fnt("TRZEWIK").dob(LocalDate.parse("1973-01-01")); + dccTestBuilder.detected(false) + .dgci("URN:UVCI:01:OS:B5921A35D6A0D696421B3E2462178297I") + .country("DE") + .testTypeRapid(true) + .testingCentre("Hochdahl") + .certificateIssuer("Dr Who") + .sampleCollection(LocalDateTime.now()); + return dccTestBuilder.toJsonString(); + } + + @Test + void genEdgc() throws IOException { + + DgcGenerator dgcGenerator = new DgcGenerator(); + DgcSigner dgcSigner = new DgcSigner(); + + String edgcJson = genSampleJson(); + + String countryCode = "DE"; + ZonedDateTime now = ZonedDateTime.now(); + ZonedDateTime expiration = now.plus(Duration.of(365, ChronoUnit.DAYS)); + long issuedAt = now.toInstant().getEpochSecond(); + long expirationSec = expiration.toInstant().getEpochSecond(); + byte[] keyId = dgcSigner.keyId(keyHelper.getCert()); + // We assume that it is EC Key + int algId = -7; + + byte[] dgcCbor = dgcGenerator.genDgcCbor(edgcJson, countryCode, issuedAt, expirationSec); + + byte[] coseBytes = dgcGenerator.genCoseUnsigned(dgcCbor, keyId, algId); + byte[] hash = dgcGenerator.computeCoseSignHash(coseBytes); + + byte[] signature = dgcSigner.signHash(hash,keyHelper.getPrivateKey()); + + byte[] coseSigned = dgcGenerator.dgcSetCoseSignature(coseBytes,signature); + String edgcQR = dgcGenerator.coseToQrCode(coseSigned); + + System.out.println(edgcQR); + } +} \ No newline at end of file diff --git a/src/test/java/eu/europa/ec/dgc/generation/KeyHelper.java b/src/test/java/eu/europa/ec/dgc/generation/KeyHelper.java new file mode 100644 index 0000000..2304d6a --- /dev/null +++ b/src/test/java/eu/europa/ec/dgc/generation/KeyHelper.java @@ -0,0 +1,28 @@ +package eu.europa.ec.dgc.generation; + +import eu.europa.ec.dgc.testdata.CertificateTestUtils; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.cert.Certificate; + +public class KeyHelper { + private final Certificate cert; + private final PrivateKey privateKey; + + public Certificate getCert() { + return cert; + } + + public PrivateKey getPrivateKey() { + return privateKey; + } + + public KeyHelper() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("ec"); + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + + cert = CertificateTestUtils.generateCertificate(keyPair, "DE", "DCC Gen Lib Test"); + privateKey = keyPair.getPrivate(); + } +} diff --git a/src/test/java/eu/europa/ec/dgc/signing/SignedCertificateMessageBuilderTest.java b/src/test/java/eu/europa/ec/dgc/signing/SignedCertificateMessageBuilderTest.java index 716127a..1a1e011 100644 --- a/src/test/java/eu/europa/ec/dgc/signing/SignedCertificateMessageBuilderTest.java +++ b/src/test/java/eu/europa/ec/dgc/signing/SignedCertificateMessageBuilderTest.java @@ -29,7 +29,6 @@ import java.util.Collection; import org.bouncycastle.asn1.cms.CMSObjectIdentifiers; import org.bouncycastle.cert.X509CertificateHolder; -import org.bouncycastle.cms.CMSException; import org.bouncycastle.cms.CMSProcessableByteArray; import org.bouncycastle.cms.CMSSignedData; import org.bouncycastle.cms.SignerInformation;