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());
+ }
+}