diff --git a/packages/web5/lib/src/vc/vc.dart b/packages/web5/lib/src/vc/vc.dart new file mode 100644 index 0000000..aa1b11d --- /dev/null +++ b/packages/web5/lib/src/vc/vc.dart @@ -0,0 +1,149 @@ +import 'package:uuid/uuid.dart'; +import 'package:web5/web5.dart'; + +class CredentialSchema { + String id; + String? type; + + CredentialSchema({ + required this.id, + this.type, + }); + + Map toJson() { + return { + 'type': type, + 'id': id, + }; + } +} + +class VerifiableCredential { + // https://www.w3.org/TR/vc-data-model/#contexts + static final baseContext = 'https://www.w3.org/2018/credentials/v1'; + // https://www.w3.org/TR/vc-data-model/#dfn-type + static final baseType = 'VerifiableCredential'; + + // https://www.w3.org/TR/vc-data-model/#contexts + List context; + // https://www.w3.org/TR/vc-data-model/#dfn-type + List type; + // https://www.w3.org/TR/vc-data-model/#issuer + String issuer; + // https://www.w3.org/TR/vc-data-model/#credential-subject + String subject; + Map data; + // https://www.w3.org/TR/vc-data-model/#identifiers + String id; + // https://www.w3.org/TR/vc-data-model/#issuance-date + String issuanceDate; + // https://www.w3.org/TR/vc-data-model/#expiration + String? expirationDate; + // https://www.w3.org/TR/vc-data-model-2.0/#data-schemas + List? credentialSchema; + + VerifiableCredential._({ + required this.context, + required this.type, + required this.issuer, + required this.subject, + required this.data, + required this.issuanceDate, + required this.id, + this.expirationDate, + this.credentialSchema = const [], + }); + + static VerifiableCredential create({ + required String issuer, + required String subject, + required Map data, + List? context, + List? type, + String? id, + DateTime? issuanceDate, + DateTime? expirationDate, + List credentialSchema = const [], + }) { + final uuid = Uuid(); + + context = context ?? [baseContext]; + type = type ?? [baseType]; + id = id ?? 'urn:vc:uuid:${uuid.v4()}'; + issuanceDate = issuanceDate ?? DateTime.now(); + + return VerifiableCredential._( + context: context, + type: type, + issuer: issuer, + subject: subject, + data: data, + id: id, + issuanceDate: issuanceDate.toString(), + expirationDate: expirationDate?.toString(), + credentialSchema: credentialSchema, + ); + } + + Future sign( + BearerDid bearerDid, + ) async { + final claims = JwtClaims( + iss: issuer, + jti: id, + sub: subject, + ); + + final issuanceDateTime = DateTime.parse(issuanceDate); + claims.nbf = issuanceDateTime.millisecondsSinceEpoch ~/ 1000; + + if (expirationDate != null) { + final expirationDateTime = DateTime.parse(expirationDate!); + claims.exp = expirationDateTime.millisecondsSinceEpoch ~/ 1000; + } + + claims.misc = {'vc': toJson()}; + + return await Jwt.sign(did: bearerDid, payload: claims); + } + + factory VerifiableCredential.fromJson(Map json) { + final credentialSubject = json['credentialSubject'] as Map; + final subject = credentialSubject.remove('id'); + final credentialSchema = (json['credentialSchema'] as List) + .map((e) => CredentialSchema(id: e['id'], type: e['type'])).toList(); + final context = (json['@context'] as List).cast(); + final type = (json['type'] as List).cast(); + + return VerifiableCredential._( + issuer: json['issuer'], + subject: subject, + data: credentialSubject, + id: json['id'], + context: context, + type: type, + issuanceDate: json['issuanceDate'], + expirationDate: json['expirationDate'], + credentialSchema: credentialSchema, + ); + } + + Map toJson() { + return { + '@context': context, + 'type': type, + 'issuer': issuer, + 'credentialSubject': { + 'id': subject, + ...data, + }, + 'id': id, + 'issuanceDate': issuanceDate, + if (expirationDate != null) 'expirationDate': expirationDate, + if (credentialSchema != null) + 'credentialSchema': credentialSchema!.map( + (e) => e.toJson(), + ).toList(), + }; + } +} diff --git a/packages/web5/lib/src/vc/vc_jwt.dart b/packages/web5/lib/src/vc/vc_jwt.dart new file mode 100644 index 0000000..eed6112 --- /dev/null +++ b/packages/web5/lib/src/vc/vc_jwt.dart @@ -0,0 +1,95 @@ +import 'package:web5/src/jwt.dart'; +import 'package:web5/src/vc/vc.dart'; + +class DecodedVcJwt { + VerifiableCredential vc; + DecodedJwt jwt; + + DecodedVcJwt(this.vc, this.jwt); + + static DecodedVcJwt decode(String jwt) { + final decoded = Jwt.decode(jwt); + + if (decoded.claims.misc == null || decoded.claims.misc!['vc'] == null) { + throw Exception('vc-jwt missing vc claims'); + } + + final vc = VerifiableCredential.fromJson(decoded.claims.misc!['vc']); + + // the following conditionals are included to conform with the jwt decoding section + // of the specification defined here: https://www.w3.org/TR/vc-data-model/#jwt-decoding + if (decoded.claims.iss != null) { + vc.issuer = decoded.claims.iss!; + } + + if (decoded.claims.jti != null) { + vc.id = decoded.claims.jti!; + } + + if (decoded.claims.sub != null) { + vc.subject = decoded.claims.sub!; + } + + if (decoded.claims.exp != null) { + vc.expirationDate = + DateTime.fromMillisecondsSinceEpoch(decoded.claims.exp! * 1000) + .toString(); + } + + if (decoded.claims.nbf != null) { + vc.issuanceDate = + DateTime.fromMillisecondsSinceEpoch(decoded.claims.nbf! * 1000) + .toString(); + } + + return DecodedVcJwt(vc, decoded); + } + + Future verify() async { + if (jwt.header.typ != 'JWT') { + throw Exception('Invalid typ, must be "JWT"'); + } + + if (vc.issuer == '') { + throw Exception('Missing issuer'); + } + + if (vc.id == '') { + throw Exception('Missing id'); + } + + final issuanceDateTime = DateTime.parse(vc.issuanceDate); + if (DateTime.now().isBefore(issuanceDateTime)) { + throw Exception('VC cannot be used before ${vc.issuanceDate}'); + } + + if (vc.expirationDate != null) { + final expirationDateTime = DateTime.parse(vc.expirationDate!); + if (DateTime.now().isAfter(expirationDateTime)) { + throw Exception('VC expired on ${vc.expirationDate}'); + } + } + + if (vc.type.isEmpty) { + throw Exception('Missing type'); + } + + if (!vc.type.contains(VerifiableCredential.baseType)) { + throw Exception( + 'Missing base type: ${VerifiableCredential.baseContext}', + ); + } + + if (vc.context.isEmpty) { + throw Exception('Missing context'); + } + + if (!vc.context.contains(VerifiableCredential.baseContext)) { + throw Exception( + 'Missing base context: ${VerifiableCredential.baseContext}', + ); + } + + await jwt.verify(); + } +} diff --git a/packages/web5/pubspec.yaml b/packages/web5/pubspec.yaml index 4d4c461..0e0aa34 100644 --- a/packages/web5/pubspec.yaml +++ b/packages/web5/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: cryptography: ^2.7.0 pointycastle: ^3.7.3 http: ^1.2.0 + uuid: ^4.4.0 dev_dependencies: lints: ^3.0.0 diff --git a/packages/web5/test/vc/vc_jwt_test.dart b/packages/web5/test/vc/vc_jwt_test.dart new file mode 100644 index 0000000..cc7ffc7 --- /dev/null +++ b/packages/web5/test/vc/vc_jwt_test.dart @@ -0,0 +1,301 @@ +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:web5/src/vc/vc.dart'; +import 'package:web5/src/vc/vc_jwt.dart'; +import 'package:web5/web5.dart'; + +void main() { + group('decode', () { + late BearerDid signerDid; + late VerifiableCredential vc; + + setUp(() async { + signerDid = await DidJwk.create(); + vc = VerifiableCredential.create( + data: {'foo': 'bar'}, + issuer: 'did:ex:pfi', + subject: 'did:ex:alice', + ); + }); + + test('returns a DecodedVcJwt with vc and jwt', () async { + final vcJwt = await vc.sign(signerDid); + + final decodedVcJwt = DecodedVcJwt.decode(vcJwt); + expect(decodedVcJwt.vc, isNotNull); + expect(decodedVcJwt.jwt, isNotNull); + }); + + test('throws if vc-jwt is missing claims', () async { + // Sign without setting claims + final header = JwtHeader(typ: 'JWT'); + final vcJwt = + await Jws.sign(did: signerDid, payload: Uint8List(0), header: header); + + expect(() => DecodedVcJwt.decode(vcJwt), throwsException); + }); + + test('throws if vc-jwt is missing vc claims', () async { + signWithoutVcClaims(VerifiableCredential vc, BearerDid bearerDid) async { + final claims = JwtClaims( + iss: vc.issuer, + jti: vc.id, + sub: vc.subject, + ); + + final issuanceDateTime = DateTime.parse(vc.issuanceDate); + claims.nbf = issuanceDateTime.millisecondsSinceEpoch ~/ 1000; + + if (vc.expirationDate != null) { + final expirationDateTime = DateTime.parse(vc.expirationDate!); + claims.exp = expirationDateTime.millisecondsSinceEpoch ~/ 1000; + } + + // deliberately omit: claims.misc = {'vc': vc.toJson()}; + + return await Jwt.sign(did: bearerDid, payload: claims); + } + + final vcJwt = await signWithoutVcClaims(vc, signerDid); + expect(() => DecodedVcJwt.decode(vcJwt), throwsException); + }); + + test('sets vc issuer to jwt iss', () async { + signAndSetCustomIss( + VerifiableCredential vc, + BearerDid bearerDid, + String iss, + ) async { + final claims = JwtClaims( + iss: iss, + jti: vc.id, + sub: vc.subject, + ); + + final issuanceDateTime = DateTime.parse(vc.issuanceDate); + claims.nbf = issuanceDateTime.millisecondsSinceEpoch ~/ 1000; + + if (vc.expirationDate != null) { + final expirationDateTime = DateTime.parse(vc.expirationDate!); + claims.exp = expirationDateTime.millisecondsSinceEpoch ~/ 1000; + } + + claims.misc = {'vc': vc.toJson()}; + + return await Jwt.sign(did: bearerDid, payload: claims); + } + + final iss = 'did:ex:custom'; + final vcJwt = await signAndSetCustomIss(vc, signerDid, iss); + final decodedVcJwt = DecodedVcJwt.decode(vcJwt); + expect(decodedVcJwt.vc.issuer, equals(iss)); + }); + + test('sets vc id to jwt jti', () async { + signAndSetCustomJti( + VerifiableCredential vc, + BearerDid bearerDid, + String jti, + ) async { + final claims = JwtClaims( + iss: vc.issuer, + jti: jti, + sub: vc.subject, + ); + + final issuanceDateTime = DateTime.parse(vc.issuanceDate); + claims.nbf = issuanceDateTime.millisecondsSinceEpoch ~/ 1000; + + if (vc.expirationDate != null) { + final expirationDateTime = DateTime.parse(vc.expirationDate!); + claims.exp = expirationDateTime.millisecondsSinceEpoch ~/ 1000; + } + + claims.misc = {'vc': vc.toJson()}; + + return await Jwt.sign(did: bearerDid, payload: claims); + } + + final jti = 'custom-id'; + final vcJwt = await signAndSetCustomJti(vc, signerDid, jti); + final decodedVcJwt = DecodedVcJwt.decode(vcJwt); + expect(decodedVcJwt.vc.id, equals(jti)); + }); + + test('sets vc subject if jwt sub', () async { + signAndSetCustomSub( + VerifiableCredential vc, + BearerDid bearerDid, + String sub, + ) async { + final claims = JwtClaims( + iss: vc.issuer, + jti: vc.id, + sub: sub, + ); + + final issuanceDateTime = DateTime.parse(vc.issuanceDate); + claims.nbf = issuanceDateTime.millisecondsSinceEpoch ~/ 1000; + + if (vc.expirationDate != null) { + final expirationDateTime = DateTime.parse(vc.expirationDate!); + claims.exp = expirationDateTime.millisecondsSinceEpoch ~/ 1000; + } + + claims.misc = {'vc': vc.toJson()}; + + return await Jwt.sign(did: bearerDid, payload: claims); + } + + final sub = 'did:ex:custom'; + final vcJwt = await signAndSetCustomSub(vc, signerDid, sub); + final decodedVcJwt = DecodedVcJwt.decode(vcJwt); + expect(decodedVcJwt.vc.subject, equals(sub)); + }); + + test('sets vc expirationDate to jwt exp', () async { + signAndSetCustomExp( + VerifiableCredential vc, + BearerDid bearerDid, + int exp, + ) async { + final claims = JwtClaims( + iss: vc.issuer, + jti: vc.id, + sub: vc.subject, + ); + + final issuanceDateTime = DateTime.parse(vc.issuanceDate); + claims.nbf = issuanceDateTime.millisecondsSinceEpoch ~/ 1000; + + claims.exp = exp; + + claims.misc = {'vc': vc.toJson()}; + + return await Jwt.sign(did: bearerDid, payload: claims); + } + + final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final nowRounded = + DateTime.fromMillisecondsSinceEpoch(exp * 1000).toString(); + + final vcJwt = await signAndSetCustomExp(vc, signerDid, exp); + final decodedVcJwt = DecodedVcJwt.decode(vcJwt); + expect(decodedVcJwt.vc.expirationDate, equals(nowRounded)); + }); + + test('sets vc issuanceDate to vc nbf', () async { + signAndSetCustomNbf( + VerifiableCredential vc, + BearerDid bearerDid, + int nbf, + ) async { + final claims = JwtClaims( + iss: vc.issuer, + jti: vc.id, + sub: vc.subject, + ); + + claims.nbf = nbf; + + if (vc.expirationDate != null) { + final expirationDateTime = DateTime.parse(vc.expirationDate!); + claims.exp = expirationDateTime.millisecondsSinceEpoch ~/ 1000; + } + claims.misc = {'vc': vc.toJson()}; + + return await Jwt.sign(did: bearerDid, payload: claims); + } + + final nbf = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final nowRounded = + DateTime.fromMillisecondsSinceEpoch(nbf * 1000).toString(); + + final vcJwt = await signAndSetCustomNbf(vc, signerDid, nbf); + final decodedVcJwt = DecodedVcJwt.decode(vcJwt); + expect(decodedVcJwt.vc.issuanceDate, equals(nowRounded)); + }); + }); + + group('verify', () { + late BearerDid signerDid; + late VerifiableCredential vc; + late DecodedVcJwt decodedVcJwt; + + setUp(() async { + signerDid = await DidJwk.create(); + vc = VerifiableCredential.create( + data: {'foo': 'bar'}, + issuer: 'did:ex:pfi', + subject: 'did:ex:alice', + ); + final vcJwt = await vc.sign(signerDid); + decodedVcJwt = DecodedVcJwt.decode(vcJwt); + }); + + test('returns successfully for well-formed VcJwt', () async { + await expectLater(decodedVcJwt.verify(), completes); + }); + + test('throws if jwt.header.typ is not "JWT"', () async { + decodedVcJwt.jwt.header.typ = 'GARBAGE'; + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + + test('throws if vc issuer is missing', () async { + decodedVcJwt.vc.issuer = ''; + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + + test('throws if vc id is missing', () async { + decodedVcJwt.vc.id = ''; + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + + test('throws if issuanceDate is in the future', () async { + final tomorrow = DateTime.now().add(Duration(hours: 24)); + decodedVcJwt.vc.issuanceDate = tomorrow.toString(); + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + + test('throws if expirationDate is in the past', () async { + final yesterday = DateTime.now().subtract(Duration(hours: 24)); + decodedVcJwt.vc.expirationDate = yesterday.toString(); + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + + test('throws if type is empty', () async { + decodedVcJwt.vc.type = []; + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + + test('throws if type does not contain base type', () async { + decodedVcJwt.vc.type = ['GARBAGE']; + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + + test('throws if context is empty', () async { + decodedVcJwt.vc.context = []; + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + + test('throws if context does not contain base context', () async { + decodedVcJwt.vc.context = ['GARBAGE']; + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + + test('throws if signature is wrong', () async { + var vcJwt = await vc.sign(signerDid); + final parts = vcJwt.split('.'); + vcJwt = [ + parts[0], + parts[1], + Base64Url.encode(List.filled(64, 0)), // malformed signature + ].join('.'); + final decodedVcJwt = DecodedVcJwt.decode(vcJwt); + + expect(() async => await decodedVcJwt.verify(), throwsException); + }); + }); +} diff --git a/packages/web5/test/vc/vc_test.dart b/packages/web5/test/vc/vc_test.dart new file mode 100644 index 0000000..9cab339 --- /dev/null +++ b/packages/web5/test/vc/vc_test.dart @@ -0,0 +1,83 @@ +import 'package:test/test.dart'; +import 'package:web5/src/vc/vc.dart'; +import 'package:web5/web5.dart'; + +void main() { + group('create', () { + test('uses default values for type, context, id, and issuanceDate', + () async { + final data = {'foo': 'bar'}; + final issuer = 'did:ex:pfi'; + final subject = 'did:ex:alice'; + final vc = VerifiableCredential.create( + data: data, + issuer: issuer, + subject: subject, + ); + + expect(vc.data, equals(data)); + expect(vc.subject, equals(subject)); + expect(vc.issuer, equals(issuer)); + expect(vc.type, equals(['VerifiableCredential'])); + expect(vc.context, equals(['https://www.w3.org/2018/credentials/v1'])); + expect(vc.issuanceDate, isNotNull); + expect(vc.id, startsWith('urn:vc:uuid:')); + expect(vc.expirationDate, isNull); + expect(vc.credentialSchema, equals([])); + }); + + test('accepts values passed in options', () async { + final data = {'foo': 'bar'}; + final issuer = 'did:ex:pfi'; + final subject = 'did:ex:alice'; + final context = ['SomeContext']; + final type = ['SomeType']; + final id = 'urn:vc:uuid:1234'; + final issuanceDate = DateTime.now().subtract(Duration(hours: 24)); + final expirationDate = DateTime.now().subtract(Duration(hours: 12)); + final credentialSchema = [ + CredentialSchema(id: 'id', type: 'CredentialType'), + ]; + + final vc = VerifiableCredential.create( + data: data, + issuer: issuer, + subject: subject, + context: context, + type: type, + id: id, + issuanceDate: issuanceDate, + expirationDate: expirationDate, + credentialSchema: credentialSchema, + ); + + expect(vc.data, equals(data)); + expect(vc.subject, equals(subject)); + expect(vc.issuer, equals(issuer)); + expect(vc.type, equals(type)); + expect(vc.context, equals(context)); + expect(DateTime.parse(vc.issuanceDate), equals(issuanceDate)); + expect(vc.id, equals(id)); + expect(DateTime.parse(vc.expirationDate!), equals(expirationDate)); + expect(vc.credentialSchema, equals(credentialSchema)); + }); + }); + + group('sign', () { + test('returns a JWT signed by the BearerDid', () async { + final did = await DidJwk.create(); + + final data = {'foo': 'bar'}; + final issuer = did.uri; + final subject = 'did:ex:alice'; + final vc = VerifiableCredential.create( + data: data, + issuer: issuer, + subject: subject, + ); + + final vcJwt = await vc.sign(did); + await expectLater(Jwt.decode(vcJwt).verify(), completes); + }); + }); +}