From 168ed77f860bbfa5a61c442ff630d59d289ea10c Mon Sep 17 00:00:00 2001 From: Tore Frederiksen Date: Thu, 2 Dec 2021 13:44:47 +0100 Subject: [PATCH] Added capability attestation code --- .../asn/SignedCapabilityAttestation.asn | 21 ++ .../src/SignedCapabilityAttestation.asd | 25 ++ .../attestation/CapabilityAttestation.java | 241 ++++++++++++++++++ .../CapabilityAttestationDecoder.java | 78 ++++++ .../CapabilityAttestationTest.java | 153 +++++++++++ 5 files changed, 518 insertions(+) create mode 100644 data-modules/output/asn/SignedCapabilityAttestation.asn create mode 100644 data-modules/src/SignedCapabilityAttestation.asd create mode 100644 src/main/java/org/tokenscript/attestation/CapabilityAttestation.java create mode 100644 src/main/java/org/tokenscript/attestation/CapabilityAttestationDecoder.java create mode 100644 src/test/java/org/tokenscript/attestation/CapabilityAttestationTest.java diff --git a/data-modules/output/asn/SignedCapabilityAttestation.asn b/data-modules/output/asn/SignedCapabilityAttestation.asn new file mode 100644 index 00000000..66f3a092 --- /dev/null +++ b/data-modules/output/asn/SignedCapabilityAttestation.asn @@ -0,0 +1,21 @@ +SignedCapabilityAttestation + +DEFINITIONS ::= +BEGIN + +SignedCapabilityAttestation ::= SEQUENCE { + capabilityAttestation CapabilityAttestation, + signatureValue BIT STRING +} + + +CapabilityAttestation ::= SEQUENCE { + uniqueId INTEGER, + sourceDomain UTF8String, -- MUST be an URI of the domain of the issuer -- + targetDomain UTF8String, -- MUST be an URI of the target domain -- + notBefore INTEGER, -- UNIX time, milliseconds since epoch -- + notAfter INTEGER, -- UNIX time, milliseconds since epoch -- + capabilities BIT STRING -- Encoding of each of the capabilities issued -- +} + +END diff --git a/data-modules/src/SignedCapabilityAttestation.asd b/data-modules/src/SignedCapabilityAttestation.asd new file mode 100644 index 00000000..40f617f4 --- /dev/null +++ b/data-modules/src/SignedCapabilityAttestation.asd @@ -0,0 +1,25 @@ + + + + + + + The actual, unsigned, capability attestation + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/org/tokenscript/attestation/CapabilityAttestation.java b/src/main/java/org/tokenscript/attestation/CapabilityAttestation.java new file mode 100644 index 00000000..4956d08d --- /dev/null +++ b/src/main/java/org/tokenscript/attestation/CapabilityAttestation.java @@ -0,0 +1,241 @@ +package org.tokenscript.attestation; + +import java.io.IOException; +import java.io.InvalidObjectException; +import java.math.BigInteger; +import java.net.MalformedURLException; +import java.net.URL; +import java.time.Instant; +import java.util.BitSet; +import java.util.Set; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bouncycastle.asn1.ASN1EncodableVector; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.DERBitString; +import org.bouncycastle.asn1.DERSequence; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.tokenscript.attestation.core.Attestable; +import org.tokenscript.attestation.core.ExceptionUtil; +import org.tokenscript.attestation.core.SignatureUtility; + +// TODO when PR 210 gets merged this should become Checkable https://github.com/TokenScript/attestation/pull/210 +public class CapabilityAttestation implements Attestable { + + // TODO should be deleted when merging PR 210 + @Override + public byte[] getCommitment() { + return new byte[0]; + } + + public enum CapabilityType { + READ("read"), // 0 + WRITE("write"), // 1 + DELEGATE("delegate"); // 2 + +// private static final Map map = Map.of( +// 0, READ, +// 1, WRITE, +// 2, DELEGATE); + private final String type; + + CapabilityType(String type) { + this.type = type; + } + public String toString() { + return type; + } + + public static CapabilityType getType(String stringType) throws IllegalArgumentException { + CapabilityType type; + switch (stringType.toLowerCase()) { + case "read": + type = CapabilityType.READ; + break; + case "write": + type = CapabilityType.WRITE; + break; + case "delegate": + type = CapabilityType.DELEGATE; + break; + default: + System.err.println("Could not parse capability type, must be either \"read\", \"write\" or \"delegate\""); + throw new IllegalArgumentException("Wrong type of identifier"); + } + return type; + } + public static CapabilityType getType(int index) throws IllegalArgumentException { + CapabilityType type; + switch (index) { + case 0: + type = CapabilityType.READ; + break; + case 1: + type = CapabilityType.WRITE; + break; + case 2: + type = CapabilityType.DELEGATE; + break; + default: + System.err.println("Could not parse capability type, must be between 0 and 2"); + throw new IllegalArgumentException("Wrong type of identifier"); + } + return type; + } + public static int getIndex(CapabilityType type) throws IllegalArgumentException { + int index; + switch (type) { + case READ: + index = 0; + break; + case WRITE: + index = 1; + break; + case DELEGATE: + index = 2; + break; + default: + System.err.println("Could not parse capability type, must be either \"read\", \"write\" or \"delegate\""); + throw new IllegalArgumentException("Wrong type of identifier"); + } + return index; + } + } + + private static final Logger logger = LogManager.getLogger(CapabilityAttestation.class); + + private final BigInteger uniqueId; + private final URL sourceDomain; + private final URL targetDomain; + private final Instant notBefore; + private final Instant notAfter; + private final Set capabilities; + private final byte[] unsignedEncoding; + private final byte[] signedEncoding; + private final byte[] signature; + private final AsymmetricKeyParameter publicKey; + + public CapabilityAttestation(BigInteger uniqueId, String sourceDomain, String targetDomain, Instant notBefore, Instant notAfter, + Set capabilities, AsymmetricCipherKeyPair signingKeys) throws MalformedURLException { + this.uniqueId = uniqueId; + this.targetDomain = new URL(targetDomain); + this.sourceDomain = new URL(sourceDomain); + this.notBefore = notBefore; + this.notAfter = notAfter; + this.capabilities = capabilities; + this.publicKey = signingKeys.getPublic(); + + try { + ASN1Sequence asn1CapAtt = makeCapabilityAtt(); + this.unsignedEncoding = asn1CapAtt.getEncoded(); + this.signature = SignatureUtility.signWithEthereum(unsignedEncoding, signingKeys.getPrivate()); + this.signedEncoding = encodeSignedCapabilityAtt(asn1CapAtt); + } catch (IOException e) { + throw ExceptionUtil.makeRuntimeException(logger, "Could not construct encoding", e); + } + constructorCheck(); + } + + public CapabilityAttestation(BigInteger uniqueId, String sourceDomain, String targetDomain, Instant notBefore, Instant notAfter, + Set capabilities, byte[] signature, AsymmetricKeyParameter verificationKey) throws MalformedURLException { + this.uniqueId = uniqueId; + this.sourceDomain = new URL(sourceDomain); + this.targetDomain = new URL(targetDomain); + this.notBefore = notBefore; + this.notAfter = notAfter; + this.capabilities = capabilities; + this.signature = signature; + this.publicKey = verificationKey; + + try { + ASN1Sequence asn1CapAtt = makeCapabilityAtt(); + this.unsignedEncoding = asn1CapAtt.getEncoded(); + this.signedEncoding = encodeSignedCapabilityAtt(asn1CapAtt); + } catch (IOException e) { + throw ExceptionUtil.makeRuntimeException(logger, "Could not construct encoding", e); + } + constructorCheck(); + } + + private void constructorCheck() { + if (!verify()) { + throw ExceptionUtil.throwException(logger, + new IllegalArgumentException("Could not verify")); + } + } + + private ASN1Sequence makeCapabilityAtt() { + ASN1EncodableVector capabilityAttestation = new ASN1EncodableVector(); + capabilityAttestation.add(new ASN1Integer(uniqueId)); + capabilityAttestation.add(new DERUTF8String(sourceDomain.toString())); + capabilityAttestation.add(new DERUTF8String(targetDomain.toString())); + capabilityAttestation.add(new ASN1Integer(notBefore.getEpochSecond()*1000)); + capabilityAttestation.add(new ASN1Integer(notAfter.getEpochSecond()*1000)); + capabilityAttestation.add(new DERBitString(convertToBitString(capabilities))); + return new DERSequence(capabilityAttestation); + } + + private byte[] encodeSignedCapabilityAtt(ASN1Sequence capabilityAtt) throws IOException { + ASN1EncodableVector signedCapAtt = new ASN1EncodableVector(); + signedCapAtt.add(capabilityAtt); + signedCapAtt.add(new DERBitString(signature)); + return new DERSequence(signedCapAtt).getEncoded(); + } + + static byte[] convertToBitString(Set capabilities) { + BitSet set = new BitSet(); + for (CapabilityType current : capabilities) { + set.set(CapabilityType.getIndex(current), true); + } + return set.toByteArray(); + } + + + public BigInteger getUniqueId() { + return uniqueId; + } + + public String getSourceDomain() { + return sourceDomain.toString(); + } + + public String getTargetDomain() { + return targetDomain.toString(); + } + + public Set getCapabilities() { + return capabilities; + } + + /** + * Return the capability attestation including signature + */ + @Override + public byte[] getDerEncoding() throws InvalidObjectException { + return signedEncoding; + } + + @Override + public boolean checkValidity() { + Timestamp timestamp = new Timestamp(notBefore.getEpochSecond()*1000); + // It is valid the time difference between expiration and start validity + timestamp.setValidity(notAfter.getEpochSecond()*1000-notBefore.getEpochSecond()*1000); + if (!timestamp.validateAgainstExpiration(notAfter.getEpochSecond()*1000)) { + logger.error("Attestation not valid at this time"); + return false; + } + return true; + } + + @Override + public boolean verify() { + if (!SignatureUtility.verifyEthereumSignature(unsignedEncoding, signature, this.publicKey)) { + logger.error("Could not verify signature"); + return false; + } + return true; + } +} diff --git a/src/main/java/org/tokenscript/attestation/CapabilityAttestationDecoder.java b/src/main/java/org/tokenscript/attestation/CapabilityAttestationDecoder.java new file mode 100644 index 00000000..533b897b --- /dev/null +++ b/src/main/java/org/tokenscript/attestation/CapabilityAttestationDecoder.java @@ -0,0 +1,78 @@ +package org.tokenscript.attestation; + +import java.io.IOException; +import java.math.BigInteger; +import java.time.Instant; +import java.util.BitSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bouncycastle.asn1.ASN1InputStream; +import org.bouncycastle.asn1.ASN1Integer; +import org.bouncycastle.asn1.ASN1Sequence; +import org.bouncycastle.asn1.DERBitString; +import org.bouncycastle.asn1.DERUTF8String; +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.tokenscript.attestation.CapabilityAttestation.CapabilityType; + +public class CapabilityAttestationDecoder implements AttestableObjectDecoder{ + private static final Logger logger = LogManager.getLogger(CapabilityAttestationDecoder.class); + private static final String DEFAULT = "default"; + + private Map idsToKeys = new HashMap<>(); + + public CapabilityAttestationDecoder(Map idsToKeys) { + this.idsToKeys = idsToKeys; + } + + public CapabilityAttestationDecoder(AsymmetricKeyParameter publicKey) { + idsToKeys.put(DEFAULT, publicKey); + } + + @Override + public CapabilityAttestation decode(byte[] encoding) throws IOException { + ASN1InputStream input = new ASN1InputStream(encoding); + ASN1Sequence asn1 = ASN1Sequence.getInstance(input.readObject()); + input.close(); + ASN1Sequence capabilityAttestation = ASN1Sequence.getInstance(asn1.getObjectAt(0)); + int innerCtr = 0; + BigInteger uniqueId = (ASN1Integer.getInstance(capabilityAttestation.getObjectAt(innerCtr++))).getValue(); + String sourceDomain = DERUTF8String.getInstance(capabilityAttestation.getObjectAt(innerCtr++)).getString(); + String targetDomain = DERUTF8String.getInstance(capabilityAttestation.getObjectAt(innerCtr++)).getString(); + long notBeforeLong = (ASN1Integer.getInstance(capabilityAttestation.getObjectAt(innerCtr++))).getValue().longValueExact(); + Instant notBefore = Instant.ofEpochSecond(notBeforeLong /1000); + long notAfterLong = (ASN1Integer.getInstance(capabilityAttestation.getObjectAt(innerCtr++))).getValue().longValueExact(); + Instant notAfter = Instant.ofEpochSecond(notAfterLong /1000); + byte[] capabilityBytes = DERBitString.getInstance(capabilityAttestation.getObjectAt(innerCtr++)).getBytes(); + Set capabilities = convertToSet(capabilityBytes); + byte[] signature = DERBitString.getInstance(asn1.getObjectAt(1)).getBytes(); + return new CapabilityAttestation(uniqueId, sourceDomain, targetDomain, notBefore, + notAfter, capabilities, signature, getPk(sourceDomain)); + } + + static Set convertToSet(byte[] capabilityBytes) { + Set capabilitySet = new HashSet<>(); + BitSet bitSet = BitSet.valueOf(capabilityBytes); + int lastBitIndex = 0; + while (bitSet.nextSetBit(lastBitIndex) != -1) { + int currentBitIndex = bitSet.nextSetBit(lastBitIndex); + CapabilityType currentType = CapabilityType.getType(currentBitIndex); + capabilitySet.add(currentType); + lastBitIndex = currentBitIndex+1; + } + return capabilitySet; + } + + private AsymmetricKeyParameter getPk(String sourceDomain) { + AsymmetricKeyParameter pk; + if (idsToKeys.get(sourceDomain) != null) { + pk = idsToKeys.get(sourceDomain); + } else { + pk = idsToKeys.get(DEFAULT); + } + return pk; + } +} diff --git a/src/test/java/org/tokenscript/attestation/CapabilityAttestationTest.java b/src/test/java/org/tokenscript/attestation/CapabilityAttestationTest.java new file mode 100644 index 00000000..4258957d --- /dev/null +++ b/src/test/java/org/tokenscript/attestation/CapabilityAttestationTest.java @@ -0,0 +1,153 @@ +package org.tokenscript.attestation; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigInteger; +import java.net.MalformedURLException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.Instant; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.params.AsymmetricKeyParameter; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.tokenscript.attestation.CapabilityAttestation.CapabilityType; +import org.tokenscript.attestation.core.SignatureUtility; + +public class CapabilityAttestationTest { + private static final String SOURCE_DOMAIN = "http://www.attesattion.io/"; + private static final String TARGET_DOMAIN = "http://www.hotelbogota.com/"; + private static final BigInteger UNIQUE_ID = new BigInteger("48646584086435845000110053401056"); + private static final Set CAPABILITIES = new HashSet(); + private static final Instant NOT_BEFORE = Clock.systemUTC().instant(); + private static final Instant NOT_AFTER = NOT_BEFORE.plusSeconds(3600); // One hour + + private static AsymmetricCipherKeyPair issuerKeys; + private static SecureRandom rand; + + @Mock + UseAttestation mockedTicket; + + @BeforeEach + public void init() { + MockitoAnnotations.initMocks(this); + } + + @BeforeAll + public static void setupKeys() throws Exception { + rand = SecureRandom.getInstance("SHA1PRNG", "SUN"); + rand.setSeed("seed".getBytes()); + issuerKeys = SignatureUtility.constructECKeysWithSmallestY(rand); + CAPABILITIES.add(CapabilityType.READ); + CAPABILITIES.add(CapabilityType.DELEGATE); + } + + @Test + public void sunshine() throws Exception { + CapabilityAttestation capabilityAttestation = new CapabilityAttestation(UNIQUE_ID, SOURCE_DOMAIN, TARGET_DOMAIN, NOT_BEFORE, NOT_AFTER, CAPABILITIES, issuerKeys); + assertTrue(capabilityAttestation.checkValidity()); + assertTrue(capabilityAttestation.verify()); + + assertEquals(UNIQUE_ID, capabilityAttestation.getUniqueId()); + assertEquals(SOURCE_DOMAIN, capabilityAttestation.getSourceDomain()); + assertEquals(TARGET_DOMAIN, capabilityAttestation.getTargetDomain()); + for (CapabilityType capability : capabilityAttestation.getCapabilities()) { + assertTrue(CAPABILITIES.contains(capability)); + } + } + + @Test + public void consistentEncoding() throws Exception { + CapabilityAttestation capabilityAttestation = new CapabilityAttestation(UNIQUE_ID, SOURCE_DOMAIN, TARGET_DOMAIN, NOT_BEFORE, NOT_AFTER, CAPABILITIES, issuerKeys); + CapabilityAttestationDecoder decoder = new CapabilityAttestationDecoder(issuerKeys.getPublic()); + CapabilityAttestation decodedAtt = decoder.decode(capabilityAttestation.getDerEncoding()); + assertArrayEquals(decodedAtt.getDerEncoding(), capabilityAttestation.getDerEncoding()); + assertTrue(decodedAtt.checkValidity()); + assertTrue(decodedAtt.verify()); + } + + @Test + public void consistentCapabilities() { + Set capabilities = Set.of(CapabilityType.DELEGATE, CapabilityType.WRITE); + byte[] capabilitiesBytes = CapabilityAttestation.convertToBitString(capabilities); + Set restoredSet = CapabilityAttestationDecoder.convertToSet(capabilitiesBytes); + assertTrue(restoredSet.contains(CapabilityType.WRITE)); + assertTrue(restoredSet.contains(CapabilityType.DELEGATE)); + assertTrue(restoredSet.size() == 2); + } + + @Test + public void mapConsistency() throws Exception { + assertEquals(CapabilityType.READ, CapabilityType.getType("read")); + assertEquals(CapabilityType.WRITE, CapabilityType.getType("write")); + assertEquals(CapabilityType.DELEGATE, CapabilityType.getType("delegate")); + } + + @Test + public void multipleKeys() throws Exception { + String otherDomain = "http://www.not-the-right-source.com"; + AsymmetricCipherKeyPair otherKeys = SignatureUtility.constructECKeysWithSmallestY(rand); + Map domainKeyMap = Map.of(otherDomain, otherKeys.getPublic(), SOURCE_DOMAIN, issuerKeys.getPublic()); + CapabilityAttestationDecoder decoder = new CapabilityAttestationDecoder(domainKeyMap); + CapabilityAttestation capabilityAttestation = new CapabilityAttestation(UNIQUE_ID, SOURCE_DOMAIN, TARGET_DOMAIN, NOT_BEFORE, NOT_AFTER, CAPABILITIES, issuerKeys); + CapabilityAttestation decodedAtt = decoder.decode(capabilityAttestation.getDerEncoding()); + assertTrue(decodedAtt.checkValidity()); + assertTrue(decodedAtt.verify()); + } + + @Test + public void noValidKey() throws Exception { + AsymmetricCipherKeyPair otherKeys = SignatureUtility.constructECKeysWithSmallestY(rand); + CapabilityAttestationDecoder decoder = new CapabilityAttestationDecoder(otherKeys.getPublic()); + CapabilityAttestation capabilityAttestation = new CapabilityAttestation(UNIQUE_ID, SOURCE_DOMAIN, TARGET_DOMAIN, NOT_BEFORE, NOT_AFTER, CAPABILITIES, issuerKeys); + assertThrows(IllegalArgumentException.class, ()-> decoder.decode(capabilityAttestation.getDerEncoding())); + } + + @Test + public void invalidTargetDomain() { + assertThrows(MalformedURLException.class, () -> new CapabilityAttestation(UNIQUE_ID, + SOURCE_DOMAIN, "not-a-domain.com", NOT_BEFORE, NOT_AFTER, CAPABILITIES, issuerKeys)); + } + + @Test + public void invalidSourceDomain() { + assertThrows(MalformedURLException.class, () -> new CapabilityAttestation(UNIQUE_ID, + "not-a-domain.com", TARGET_DOMAIN, NOT_BEFORE, NOT_AFTER, CAPABILITIES, issuerKeys)); + } + + @Test + public void wrongVerificationKey() { + AsymmetricCipherKeyPair otherKeys = SignatureUtility.constructECKeysWithSmallestY(rand); + AsymmetricCipherKeyPair wrongKeyPair = new AsymmetricCipherKeyPair(otherKeys.getPublic(), issuerKeys.getPrivate()); + assertThrows(RuntimeException.class, () -> new CapabilityAttestation(UNIQUE_ID, + SOURCE_DOMAIN, TARGET_DOMAIN, NOT_BEFORE, NOT_AFTER, CAPABILITIES, wrongKeyPair)); + } + + @Test + public void notYetValid() throws Exception { + // Only valid in 24 hours + CapabilityAttestation capabilityAttestation = new CapabilityAttestation(UNIQUE_ID, + SOURCE_DOMAIN, TARGET_DOMAIN, Instant.now().plusSeconds(3600*24), NOT_AFTER, CAPABILITIES, issuerKeys); + assertFalse(capabilityAttestation.checkValidity()); + assertTrue(capabilityAttestation.verify()); + } + + @Test + public void expired() throws Exception { + // Only valid in 24 hours + CapabilityAttestation capabilityAttestation = new CapabilityAttestation(UNIQUE_ID, + SOURCE_DOMAIN, TARGET_DOMAIN, NOT_BEFORE, NOT_BEFORE.minusSeconds(3600*24), CAPABILITIES, issuerKeys); + assertFalse(capabilityAttestation.checkValidity()); + assertTrue(capabilityAttestation.verify()); + } +}