Skip to content

Commit

Permalink
chore: add support for fetching properties w/wo namespace + bitstring (
Browse files Browse the repository at this point in the history
…eclipse-edc#4108)

chore: add support for fetching properties w/wo namespace + bitstring implementation
  • Loading branch information
wolf4ood authored Apr 12, 2024
1 parent 885936f commit db0467b
Show file tree
Hide file tree
Showing 12 changed files with 311 additions and 21 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,14 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import org.eclipse.edc.iam.verifiablecredentials.spi.RevocationListService;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.VerifiableCredential;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist.BitString;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist.StatusList2021Credential;
import org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist.StatusListStatus;
import org.eclipse.edc.spi.result.Result;
import org.eclipse.edc.util.collection.Cache;

import java.io.IOException;
import java.net.URI;
import java.util.Base64;
import java.util.BitSet;
import java.util.Map;

/**
Expand Down Expand Up @@ -68,11 +67,16 @@ private Result<Void> checkStatus(StatusListStatus status) {
return Result.failure("Credential's statusPurpose value must match the status list's purpose: '%s' != '%s'".formatted(purpose, slCredPurpose));
}

// check that the value at index in the bitset is "1"
var bytes = Base64.getUrlDecoder().decode(slCred.encodedList());
var bitset = BitSet.valueOf(bytes);
var bitStringResult = BitString.Parser.newInstance().parse(slCred.encodedList());

if (bitStringResult.failed()) {
return bitStringResult.mapTo();
}
var bitString = bitStringResult.getContent();

var index = status.getStatusListIndex();
if (bitset.get(index)) {
// check that the value at index in the bitset is "1"
if (bitString.get(index)) {
return Result.failure("Credential status is '%s', status at index %d is '1'".formatted(purpose, index));
}
return Result.success();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@
import static org.mockserver.model.HttpRequest.request;

class StatusList2021RevocationServiceTest {
private static final int NOT_REVOKED_INDEX = 42;
private static final int REVOKED_INDEX = 359;
private static final int NOT_REVOKED_INDEX = 1;
private static final int REVOKED_INDEX = 2;
private final StatusList2021RevocationService revocationService = new StatusList2021RevocationService(new ObjectMapper().registerModule(new JavaTimeModule()),
5 * 60 * 1000);
private ClientAndServer clientAndServer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public class TestData {
"id": "https://example.com/status/3#list",
"type": "StatusList2021",
"https://w3id.org/vc/status-list#statusPurpose": "revocation",
"https://w3id.org/vc/status-list#encodedList": "H4sIAAAAAAAAA-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA"
"https://w3id.org/vc/status-list#encodedList": "H4sIAAAAAAAAA+3BIQEAAAACIP+vcKozLEADAAAAAAAAAAAAAAAAAAAAvA0cOP65AEAAAA"
}
]
}
Expand All @@ -51,7 +51,7 @@ public class TestData {
"id": "https://example.com/status/3#list",
"type": "StatusList2021",
"https://w3id.org/vc/status-list#statusPurpose": "revocation",
"https://w3id.org/vc/status-list#encodedList": "H4sIAAAAAAAAA-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA"
"https://w3id.org/vc/status-list#encodedList": "H4sIAAAAAAAAA+3BIQEAAAACIP+vcKozLEADAAAAAAAAAAAAAAAAAAAAvA0cOP65AEAAAA"
}
}
""";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.List;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Supplier;

/**
* A generic result type.
Expand All @@ -48,6 +49,21 @@ public static <T> Result<T> failure(List<String> failures) {
return new Result<>(null, new Failure(failures));
}


/**
* Runs the block provided by the {@link Supplier} and wrap the return into a Result
*
* @param supplier The block to execute
* @return The Result of the supplier call. Success if no {@link Exception} were thrown. Failure otherwise
*/
public static <T> Result<T> ofThrowable(Supplier<T> supplier) {
try {
return Result.success(supplier.get());
} catch (Exception e) {
return Result.failure(e.getMessage());
}
}

/**
* Converts a {@link Optional} into a result, interpreting the Optional's value as content.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package org.eclipse.edc.spi.result;

import org.eclipse.edc.junit.assertions.AbstractResultAssert;
import org.eclipse.edc.spi.EdcException;
import org.junit.jupiter.api.Test;

import java.util.Optional;
Expand Down Expand Up @@ -256,4 +257,20 @@ void recover_shouldDoNothing_whenSucceeded() {

AbstractResultAssert.assertThat(succeededResult.recover(failingRecoverFunction)).isSucceeded();
}

@Test
void ofThrowable_success() {

var result = Result.ofThrowable(String::new);
AbstractResultAssert.assertThat(result).isSucceeded().isEqualTo("");
}

@Test
void ofThrowable_failure() {

var result = Result.ofThrowable(() -> {
throw new EdcException("Exception");
});
AbstractResultAssert.assertThat(result).isFailed().detail().contains("Exception");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,28 @@

import java.util.Map;

import static java.util.Optional.ofNullable;

public record CredentialStatus(String id, String type, Map<String, Object> additionalProperties) {
public static final String CREDENTIAL_STATUS_ID_PROPERTY = "@id";
public static final String CREDENTIAL_STATUS_TYPE_PROPERTY = "@type";


/**
* Returns a property if presents in the properties map. This method
* will try first the combination namespace + property and if
* not found will fall back to just property when fetching the property
* from the underling map
*
* @param namespace The namespace of the property
* @param property The name of the property
* @return The property if present, null otherwise
*/

public Object getProperty(String namespace, String property) {
return ofNullable(additionalProperties.get(namespace + property))
.or(() -> ofNullable(additionalProperties.get(property)))
.orElse(null);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.util.HashMap;
import java.util.Map;

import static java.util.Optional.ofNullable;
import static org.eclipse.edc.iam.verifiablecredentials.spi.VcConstants.VC_PREFIX;

/**
Expand All @@ -40,6 +41,23 @@ public void setClaim(String name, Object value) {
claims.put(name, value);
}


/**
* Returns a claim if presents in the claims map. This method
* will try first the combination namespace + property and if
* not found will fall back to just property when fetching the claim
* from the underling map
*
* @param namespace The namespace of the property
* @param property The name of the property
* @return The claim if present, null otherwise
*/
public Object getClaim(String namespace, String property) {
return ofNullable(claims.get(namespace + property))
.or(() -> ofNullable(claims.get(property)))
.orElse(null);
}

public String getId() {
return id;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2024 Bayerische Motoren Werke Aktiengesellschaft (BMW AG)
*
* This program and the accompanying materials are made available under the
* terms of the Apache License, Version 2.0 which is available at
* https://www.apache.org/licenses/LICENSE-2.0
*
* SPDX-License-Identifier: Apache-2.0
*
* Contributors:
* Bayerische Motoren Werke Aktiengesellschaft (BMW AG) - initial API and implementation
*
*/

package org.eclipse.edc.iam.verifiablecredentials.spi.model.statuslist;

import org.eclipse.edc.spi.result.Result;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.zip.GZIPInputStream;

/**
* Representation of <a href="https://www.w3.org/TR/2023/WD-vc-status-list-20230427/#bitstring-encoding">StatusList2021Credential#bitstring</a>
*/
public class BitString {

private final boolean leftToRightIndexing;
private final byte[] bits;
private final int bitsPerByte = 8;

private BitString(byte[] bits, boolean leftToRightIndexing) {
this.bits = bits;
this.leftToRightIndexing = leftToRightIndexing;
}

/**
* Checks if the bit at the input index is `1`. The input index should be in the
* bound (0-bitstring_length - 1). The default bit order is left to right, which means
* that for a byte 00000001, the bit value of that first (zeroth) index is `0` and
* the last index (seventh) is `1`
*
* @param idx The bit index to check
* @return True if `1`, false otherwise
*/
public boolean get(int idx) {

if (idx < 0 || idx >= length()) {
throw new IllegalArgumentException("Index out of range 0-%s".formatted(length()));
}
var byteIdx = idx / bitsPerByte;
var bitIdx = idx % bitsPerByte;
var shift = leftToRightIndexing ? (7 - bitIdx) : bitIdx;
return (bits[byteIdx] & (1L << shift)) != 0;
}

public int length() {
return bits.length * bitsPerByte;
}

/**
* Parser configuration for {@link BitString}
*/
public static final class Parser {
private boolean leftToRightIndexing = true;
private Base64.Decoder decoder = Base64.getDecoder();

private Parser() {
}

public static Parser newInstance() {
return new Parser();
}

public Parser leftToRightIndexing(boolean leftToRightIndexing) {
this.leftToRightIndexing = leftToRightIndexing;
return this;
}

public Parser decoder(Base64.Decoder decoder) {
this.decoder = decoder;
return this;
}

public Result<BitString> parse(String encodedList) {
return Result.ofThrowable(() -> decoder.decode(encodedList))
.compose(this::unGzip)
.map(bytes -> new BitString(bytes, leftToRightIndexing));
}

private Result<byte[]> unGzip(byte[] bytes) {
try (var inputStream = new GZIPInputStream(new ByteArrayInputStream(bytes))) {
try (var outputStream = new ByteArrayOutputStream()) {
inputStream.transferTo(outputStream);
return Result.success(outputStream.toByteArray());
}
} catch (IOException e) {
return Result.failure("Failed to ungzip encoded list: %s".formatted(e.getMessage()));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@
public class StatusList2021Credential extends VerifiableCredential {
public static final String STATUSLIST_2021_TYPE = "StatusList2021";
public static final String STATUSLIST_2021_CREDENTIAL = STATUSLIST_2021_TYPE + "Credential";
public static final String STATUS_LIST_ENCODED_LIST = STATUSLIST_2021_PREFIX + "encodedList";
public static final String STATUS_LIST_CREDENTIAL = STATUSLIST_2021_PREFIX + "statusListCredential";
public static final String STATUS_LIST_INDEX = STATUSLIST_2021_PREFIX + "statusListIndex";
public static final String STATUS_LIST_PURPOSE = STATUSLIST_2021_PREFIX + "statusPurpose";
public static final String STATUS_LIST_ENCODED_LIST_LITERAL = "encodedList";
public static final String STATUS_LIST_ENCODED_LIST = STATUSLIST_2021_PREFIX + STATUS_LIST_ENCODED_LIST_LITERAL;
public static final String STATUS_LIST_CREDENTIAL_LITERAL = "statusListCredential";
public static final String STATUS_LIST_CREDENTIAL = STATUSLIST_2021_PREFIX + STATUS_LIST_CREDENTIAL_LITERAL;
public static final String STATUS_LIST_INDEX_LITERAL = "statusListIndex";
public static final String STATUS_LIST_INDEX = STATUSLIST_2021_PREFIX + STATUS_LIST_INDEX_LITERAL;
public static final String STATUS_LIST_PURPOSE_LITERAL = "statusPurpose";
public static final String STATUS_LIST_PURPOSE = STATUSLIST_2021_PREFIX + STATUS_LIST_PURPOSE_LITERAL;

private StatusList2021Credential() {
}
Expand All @@ -51,11 +55,11 @@ public static StatusList2021Credential parse(VerifiableCredential rawCredential)
}

public String encodedList() {
return (String) credentialSubject.get(0).getClaims().get(STATUS_LIST_ENCODED_LIST);
return (String) credentialSubject.get(0).getClaim(STATUSLIST_2021_PREFIX, STATUS_LIST_ENCODED_LIST_LITERAL);
}

public String statusPurpose() {
return (String) credentialSubject.get(0).getClaims().get(STATUS_LIST_PURPOSE);
return (String) credentialSubject.get(0).getClaim(STATUSLIST_2021_PREFIX, STATUS_LIST_PURPOSE_LITERAL);
}

public static class Builder extends VerifiableCredential.Builder<StatusList2021Credential, Builder> {
Expand Down Expand Up @@ -83,10 +87,10 @@ public StatusList2021Credential build() {

// check mandatory fields of the credentialSubject object
var subject = instance.credentialSubject.get(0);
if (!subject.getClaims().containsKey(STATUS_LIST_ENCODED_LIST)) {
if (subject.getClaim(STATUSLIST_2021_PREFIX, STATUS_LIST_ENCODED_LIST_LITERAL) == null) {
throw new IllegalArgumentException("Status list credentials must contain a 'credentialSubject.encodedList' field.");
}
if (!subject.getClaims().containsKey(STATUS_LIST_PURPOSE)) {
if (subject.getClaim(STATUSLIST_2021_PREFIX, STATUS_LIST_PURPOSE_LITERAL) == null) {
throw new IllegalArgumentException("Status list credentials must contain a 'credentialSubject.statusPurpose' field.");
}
return instance;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import java.util.Map;

import static java.util.Optional.ofNullable;
import static org.eclipse.edc.iam.verifiablecredentials.spi.VcConstants.STATUSLIST_2021_PREFIX;

/**
* Specialized {@code credentialStatus}, that contains information mandated by the StatusList2021 standard.
Expand All @@ -38,20 +39,20 @@ public static StatusListStatus parse(CredentialStatus status) {
.map(Object::toString)
.orElseThrow(() -> new IllegalArgumentException(missingProperty(StatusList2021Credential.STATUS_LIST_CREDENTIAL)));

instance.statusListIndex = ofNullable(status.additionalProperties().get(StatusList2021Credential.STATUS_LIST_INDEX))
instance.statusListIndex = ofNullable(status.getProperty(STATUSLIST_2021_PREFIX, StatusList2021Credential.STATUS_LIST_INDEX_LITERAL))
.map(Object::toString)
.map(Integer::parseInt)
.orElseThrow(() -> new IllegalArgumentException(missingProperty(StatusList2021Credential.STATUS_LIST_INDEX)));

instance.statusListPurpose = ofNullable(status.additionalProperties().get(StatusList2021Credential.STATUS_LIST_PURPOSE))
instance.statusListPurpose = ofNullable(status.getProperty(STATUSLIST_2021_PREFIX, StatusList2021Credential.STATUS_LIST_PURPOSE_LITERAL))
.map(Object::toString)
.orElseThrow(() -> new IllegalArgumentException(missingProperty(StatusList2021Credential.STATUS_LIST_PURPOSE)));

return instance;
}

private static Object getId(CredentialStatus status) {
var credentialId = status.additionalProperties().get(StatusList2021Credential.STATUS_LIST_CREDENTIAL);
var credentialId = status.getProperty(STATUSLIST_2021_PREFIX, StatusList2021Credential.STATUS_LIST_CREDENTIAL_LITERAL);
if (credentialId instanceof Map<?, ?> map) {
return map.get("@id");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,17 @@ void serDes() throws JsonProcessingException {
.containsKey("complex")
.hasEntrySatisfying("complex", o -> assertThat(o).isInstanceOf(Map.class));
}

@Test
void getClaim() {
var namespace = "http://namespace#";
var cred = CredentialSubject.Builder.newInstance()
.claim("key", "val")
.claim(namespace + "key1", "val1")
.build();


assertThat(cred.getClaim(namespace, "key")).isEqualTo("val");
assertThat(cred.getClaim(namespace, "key1")).isEqualTo("val1");
}
}
Loading

0 comments on commit db0467b

Please sign in to comment.