Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Select Credentials #81

Merged
merged 12 commits into from
May 7, 2024
274 changes: 274 additions & 0 deletions packages/web5/lib/src/pexv2/pd.dart
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:convert/convert.dart';
import 'package:json_path/json_path.dart';
import 'package:json_schema/json_schema.dart';

/// PresentationDefinition represents a DIF Presentation Definition defined [here].
/// Presentation Definitions are objects that articulate what proofs a Verifier requires.
///
/// [here]: https://identity.foundation/presentation-exchange/#presentation-definition
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
class PresentationDefinition {
String id;
String? name;
String? purpose;
List<InputDescriptor> inputDescriptors;

PresentationDefinition({
required this.id,
this.name,
this.purpose,
required this.inputDescriptors,
});

factory PresentationDefinition.fromJson(Map<String, dynamic> json) =>
PresentationDefinition(
id: json['id'],
name: json['name'],
purpose: json['purpose'],
inputDescriptors: List<InputDescriptor>.from(
json['input_descriptors'].map((x) => InputDescriptor.fromJson(x)),
),
);

Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'purpose': purpose,
'input_descriptors':
List<dynamic>.from(inputDescriptors.map((x) => x.toJson())),
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
};

List<String> selectCredentials(List<String> vcJwts) {
final Set<String> matches = {};

for (final inputDescriptor in inputDescriptors) {
final matchingVcJwts = inputDescriptor.selectCredentials(vcJwts);
if (matchingVcJwts.isEmpty) {
return [];
}
matches.addAll(matchingVcJwts);
}

return matches.toList();
}
}

class _TokenizedField {
List<String> paths;
String token;

_TokenizedField({required this.paths, required this.token});
}

/// InputDescriptor represents a DIF Input Descriptor defined [here].
/// Input Descriptors are used to describe the information a Verifier requires of a Holder.
///
/// [here]: https://identity.foundation/presentation-exchange/#input-descriptor
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
class InputDescriptor {
String id;
String? name;
String? purpose;
Constraints constraints;

InputDescriptor({
required this.id,
this.name,
this.purpose,
required this.constraints,
});

factory InputDescriptor.fromJson(Map<String, dynamic> json) =>
InputDescriptor(
id: json['id'],
name: json['name'],
purpose: json['purpose'],
constraints: Constraints.fromJson(json['constraints']),
);

Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'purpose': purpose,
'constraints': constraints.toJson(),
};

String _generateRandomToken() {
final rand = Random.secure();
final bytes = Uint8List(16);
for (int i = 0; i < 16; i++) {
bytes[i] = rand.nextInt(256);
}
return hex.encode(bytes);
}

List<String> selectCredentials(List<String> vcJWTs) {
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
final List<String> answer = [];
final List<_TokenizedField> tokenizedField = [];
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
final schemaMap = {
'\$schema': 'http://json-schema.org/draft-07/schema#',
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
'type': 'object',
'properties': {},
'required': [],
};

// Populate JSON schema and generate tokens for each field
for (var field in constraints.fields ?? []) {
final token = _generateRandomToken();
tokenizedField
.add(_TokenizedField(token: token, paths: field.path ?? []));

final properties = schemaMap['properties'] as Map<String, dynamic>;

if (field.filter != null) {
properties[token] = field.filter.toJson();
} else {
final anyType = {
'type': ['string', 'number', 'boolean', 'object', 'array'],
};
properties[token] = anyType;
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
}
final required = schemaMap['required'] as List<dynamic>;
required.add(token);
}
final jsonSchema = JsonSchema.create(schemaMap);

// Tokenize each vcJwt and validate it against the JSON schema
for (var vcJWT in vcJWTs) {
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
final decoded = json.decode(vcJWT);

final selectionCandidate = <String, dynamic>{};

for (var tokenPath in tokenizedField) {
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
for (var path in tokenPath.paths) {
final value = JsonPath(path)
.read(decoded)
.firstOrNull; // Custom function needed to handle JSON paths.
if (value != null) {
selectionCandidate[tokenPath.token] = value;
break;
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this loop ends and no paths matched, there's no need to continue evaluating the current vcJwt

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

}

final validationResult = jsonSchema.validate(selectionCandidate);
if (validationResult.isValid) {
answer.add(vcJWT);
}
}

return answer;
}
}

/// Constraints contains the requirements for a given Input Descriptor.
class Constraints {
List<Field>? fields;

Constraints({this.fields});

factory Constraints.fromJson(Map<String, dynamic> json) => Constraints(
fields: json['fields'] == null
? null
: List<Field>.from(json['fields'].map((x) => Field.fromJson(x))),
);

Map<String, dynamic> toJson() => {
'fields': fields == null
? null
: List<dynamic>.from(fields!.map((x) => x.toJson())),
};
mistermoe marked this conversation as resolved.
Show resolved Hide resolved
}

/// Field contains the requirements for a given field within a proof.
class Field {
String? id;
String? name;
List<String>? path;
String? purpose;
Filter? filter;
bool? optional;
Optionality? predicate;

Field({
this.id,
this.name,
this.path,
this.purpose,
this.filter,
this.optional,
this.predicate,
});

factory Field.fromJson(Map<String, dynamic> json) => Field(
id: json['id'],
name: json['name'],
path: json['path'] == null ? null : List<String>.from(json['path']),
purpose: json['purpose'],
filter: json['filter'] == null ? null : Filter.fromJson(json['filter']),
optional: json['optional'],
predicate: json['predicate'] == null
? null
: optionalityValues.map[json['predicate']],
);

Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'path': path,
'purpose': purpose,
'filter': filter?.toJson(),
'optional': optional,
'predicate':
predicate == null ? null : optionalityValues.reverse[predicate],
};
}

enum Optionality { required, preferred }

final optionalityValues = EnumValues({
'preferred': Optionality.preferred,
'required': Optionality.required,
});

/// Filter is a JSON Schema that is applied against the value of a field.
class Filter {
String? type;
String? pattern;
String? constValue;
Filter? contains;

Filter({
this.type,
this.pattern,
this.constValue,
this.contains,
});

factory Filter.fromJson(Map<String, dynamic> json) => Filter(
type: json['type'],
pattern: json['pattern'],
constValue: json['const'],
contains:
json['contains'] == null ? null : Filter.fromJson(json['contains']),
);

Map<String, dynamic> toJson() => {
'type': type,
'pattern': pattern,
'const': constValue,
'contains': contains?.toJson(),
};
}

/// Helper class for handling enums in JSON.
// TODO might not need this
class EnumValues<T> {
Map<String, T> map;
Map<T, String> reverseMap;

EnumValues(this.map) : reverseMap = map.map((k, v) => MapEntry(v, k));

Map<T, String> get reverse => reverseMap;
}
2 changes: 2 additions & 0 deletions packages/web5/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ dependencies:
pointycastle: ^3.7.3
http: ^1.2.0
uuid: ^4.4.0
json_path: ^0.7.1
json_schema: ^5.1.7

dev_dependencies:
lints: ^3.0.0
Expand Down
21 changes: 21 additions & 0 deletions packages/web5/test/helpers/test_vector_helpers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import 'dart:convert';
import 'dart:io';

final thisDir = Directory.current.path;
final vectorDir = '$thisDir/../../web5-spec/test-vectors/';

Map<String, dynamic> getJsonVectors(String vectorSubPath) {
final vectorPath = '$vectorDir/$vectorSubPath';
final file = File(vectorPath);

try {
// Read the file as a string
final contents = file.readAsStringSync();
return json.decode(contents);

} catch (e) {
// If encountering an error, print it
throw Exception('Failed to load verify test vectors: $e');
}
}

74 changes: 74 additions & 0 deletions packages/web5/test/pexv2/pd_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import 'package:test/test.dart';
import 'package:web5/src/pexv2/pd.dart';

import '../helpers/test_vector_helpers.dart';


class SelectCredentialTestVector {
String description;

// Input
PresentationDefinition inputPresentationDefinition;
List<String> inputVcJwts;

// output
List<String> outputSelectedCredentials;
bool errors;

SelectCredentialTestVector({
required this.description,
required this.inputPresentationDefinition,
required this.inputVcJwts,
required this.outputSelectedCredentials,
this.errors = false,
});

factory SelectCredentialTestVector.fromJson(Map<String, dynamic> json) {
return SelectCredentialTestVector(
description: json['description'],
inputPresentationDefinition: PresentationDefinition.fromJson(
json['input']['presentationDefinition'],
),
inputVcJwts: json['input']['credentialJwts'],
outputSelectedCredentials: json['output']['selectedCredentials'],
errors: json['errors'],
);
}
}

void main() {
group('select credentials', () {
group('vectors', () {
late List<SelectCredentialTestVector> vectors;

setUpAll(() {
final vectorsJson = getJsonVectors('presentation_exchange/select_credentials.json');
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

running into trouble reading the vectors from the submodule. i'll take another stab at it in the morning

vectors = (vectorsJson['vectors'] as List<Map<String, dynamic>>)
.map(SelectCredentialTestVector.fromJson)
.toList();
});

for (final vector in vectors) {
test(vector.description, () async {
try {
final matchingVcJwts = vector.inputPresentationDefinition
.selectCredentials(vector.inputVcJwts);

if (vector.errors == true) {
fail('Expected an error but none was thrown');
}

expect(
Set.from(matchingVcJwts),
Set.from(vector.outputSelectedCredentials),
);
} catch (e) {
if (vector.errors == false) {
fail('Expected no error but got: $e');
}
}
});
}
});
});
}
Loading