From fe87e513d7e5a5f38b4021477223b577a8f945f4 Mon Sep 17 00:00:00 2001 From: River <60234090+StonePack@users.noreply.github.com> Date: Wed, 10 Apr 2024 10:24:13 -0700 Subject: [PATCH] feature/dht-create (#55) * moved from old fork * updated create function with algorithm handling * updated key generation and storage to match web did * revert key manager changes --- packages/web5/lib/src/crypto/crypto.dart | 11 + packages/web5/lib/src/crypto/ed25519.dart | 12 + packages/web5/lib/src/crypto/secp256k1.dart | 12 + .../dids/did_core/did_document_metadata.dart | 7 + .../web5/lib/src/dids/did_dht/did_dht.dart | 124 ++++++++++ .../src/dids/did_dht/dns_packet/answer.dart | 16 ++ .../src/dids/did_dht/dns_packet/header.dart | 51 +++- .../src/dids/did_dht/dns_packet/packet.dart | 221 ++++++++++++++++++ .../src/dids/did_dht/registered_did_type.dart | 51 ++++ packages/web5/lib/src/encoders/zbase.dart | 53 +++++ packages/web5/pubspec.yaml | 1 - 11 files changed, 547 insertions(+), 12 deletions(-) create mode 100644 packages/web5/lib/src/dids/did_dht/registered_did_type.dart create mode 100644 packages/web5/lib/src/encoders/zbase.dart diff --git a/packages/web5/lib/src/crypto/crypto.dart b/packages/web5/lib/src/crypto/crypto.dart index 101840b..8fd09b2 100644 --- a/packages/web5/lib/src/crypto/crypto.dart +++ b/packages/web5/lib/src/crypto/crypto.dart @@ -37,6 +37,17 @@ class Crypto { } } + static Uint8List publicKeyToBytes(Jwk publicKey) { + switch (publicKey.kty) { + case Ed25519.kty: + return Ed25519.publicKeyToBytes(publicKey: publicKey); + case Secp256k1.kty: + return Secp256k1.publicKeyToBytes(publicKey: publicKey); + default: + throw Exception('unsupported kty: ${publicKey.kty}'); + } + } + static Future sign(Jwk privateKeyJwk, Uint8List payload) { switch (privateKeyJwk.kty) { case Ecdsa.kty: diff --git a/packages/web5/lib/src/crypto/ed25519.dart b/packages/web5/lib/src/crypto/ed25519.dart index 641696f..2cc65c0 100644 --- a/packages/web5/lib/src/crypto/ed25519.dart +++ b/packages/web5/lib/src/crypto/ed25519.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:typed_data'; import 'package:cryptography/cryptography.dart' as crypto; @@ -51,6 +52,17 @@ class Ed25519 { return privateKeyJwk; } + static Uint8List publicKeyToBytes({required Jwk publicKey}) { + if (publicKey.x == null) { + throw Error(); + } + + final Uint8List encodedKey = utf8.encode(publicKey.x!); + final String base64Url = base64UrlEncode(encodedKey); + + return utf8.encode(base64Url); + } + static Future sign(Jwk privateKey, Uint8List payload) async { final privateKeyBytes = Base64Url.decode(privateKey.d!); diff --git a/packages/web5/lib/src/crypto/secp256k1.dart b/packages/web5/lib/src/crypto/secp256k1.dart index 1567b8a..b51d6b9 100644 --- a/packages/web5/lib/src/crypto/secp256k1.dart +++ b/packages/web5/lib/src/crypto/secp256k1.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math'; import 'dart:typed_data'; @@ -79,6 +80,17 @@ class Secp256k1 { return Future.value(privateKeyJwk); } + static Uint8List publicKeyToBytes({required Jwk publicKey}) { + if (publicKey.x == null) { + throw Error(); + } + + final Uint8List encodedKey = utf8.encode(publicKey.x!); + final String base64Url = base64UrlEncode(encodedKey); + + return utf8.encode(base64Url); + } + static Future sign(Jwk privateKeyJwk, Uint8List payload) { final sha256 = SHA256Digest(); diff --git a/packages/web5/lib/src/dids/did_core/did_document_metadata.dart b/packages/web5/lib/src/dids/did_core/did_document_metadata.dart index 6843099..b4b39eb 100644 --- a/packages/web5/lib/src/dids/did_core/did_document_metadata.dart +++ b/packages/web5/lib/src/dids/did_core/did_document_metadata.dart @@ -1,3 +1,5 @@ +import 'package:web5/src/dids/did_dht/registered_did_type.dart'; + /// contains metadata about the DID document contained in the didDocument /// property. This metadata typically does not change between invocations of /// the resolve and resolveRepresentation functions unless the DID document @@ -51,6 +53,8 @@ class DidDocumentMetadata { /// the scope of the containing DID document. final String? canonicalId; + final List? types; + const DidDocumentMetadata({ this.created, this.updated, @@ -60,6 +64,7 @@ class DidDocumentMetadata { this.nextVersionId, this.equivalentId, this.canonicalId, + this.types, }); Map toJson() { @@ -72,6 +77,7 @@ class DidDocumentMetadata { 'nextVersionId': nextVersionId, 'equivalentId': equivalentId, 'canonicalId': canonicalId, + 'types': types?.map((e) => e.value) ?? [], }; json.removeWhere((key, value) => value == null); @@ -105,6 +111,7 @@ class DidDocumentMetadata { nextVersionId, equivalentId, canonicalId, + types, ); } } diff --git a/packages/web5/lib/src/dids/did_dht/did_dht.dart b/packages/web5/lib/src/dids/did_dht/did_dht.dart index 2607502..41d818d 100644 --- a/packages/web5/lib/src/dids/did_dht/did_dht.dart +++ b/packages/web5/lib/src/dids/did_dht/did_dht.dart @@ -1,11 +1,15 @@ import 'dart:io'; +import 'dart:typed_data'; import 'package:web5/src/crypto.dart'; +import 'package:web5/src/dids/bearer_did.dart'; import 'package:web5/src/dids/did.dart'; import 'package:web5/src/dids/did_core.dart'; import 'package:web5/src/dids/did_dht/dns_packet.dart'; +import 'package:web5/src/dids/did_dht/registered_did_type.dart'; import 'package:web5/src/dids/did_method_resolver.dart'; import 'package:web5/src/encoders.dart'; +import 'package:web5/src/encoders/zbase.dart'; final Set txtEntryNames = {'vm', 'auth', 'asm', 'agm', 'inv', 'del'}; @@ -14,6 +18,126 @@ class DidDht { static final resolver = DidMethodResolver(name: methodName, resolve: resolve); + static Future create({ + KeyManager? keyManager, + List? alsoKnownAs, + List? controllers, + String? gatewayUri, + bool? publish, + List? services, + List? types, + List? verificationMethods, + }) async { + final AlgorithmId idAlgorithm = AlgorithmId.ed25519; + keyManager ??= InMemoryKeyManager(); + + // Generate random key material for the Identity Key. + final String keyAlias = await keyManager.generatePrivateKey(idAlgorithm); + final Jwk identityKey = await keyManager.getPublicKey(keyAlias); + + final String didUri = identityKeyToIdentifier(identityKey: identityKey); + final DidDocument doc = DidDocument( + id: didUri, + alsoKnownAs: alsoKnownAs, + controller: controllers ?? didUri, + ); + + // If the given verification methods do not contain an Identity Key, add one. + final List methodsToAdd = + verificationMethods ?? []; + + final Iterable identityMethods = + methodsToAdd.where( + (vm) => vm.id?.split('#').last == '0', + ); + + if (identityMethods.isEmpty) { + methodsToAdd.add( + DidCreateVerificationMethod( + algorithm: AlgorithmId.ed25519, + id: '0', + type: 'JsonWebKey', + controller: didUri, + purposes: [ + VerificationPurpose.authentication, + VerificationPurpose.assertionMethod, + VerificationPurpose.capabilityDelegation, + VerificationPurpose.capabilityInvocation, + ], + ), + ); + } + + // Generate random key material for the Identity Key and any additional verification methods. + // Add verification methods to the DID document. + for (final DidCreateVerificationMethod vm in methodsToAdd) { + // Generate a random key for the verification method, or if its the Identity Key's + // verification method (`id` is 0) use the key previously generated. + late Jwk publicKey; + + if (vm.id?.split('#').last == '0') { + publicKey = identityKey; + } else { + String alias = await keyManager.generatePrivateKey(vm.algorithm); + publicKey = await keyManager.getPublicKey(alias); + } + + // Use the given ID, the key's ID, or the key's thumbprint as the verification method ID. + String methodId = vm.id ?? publicKey.kid ?? publicKey.computeThumbprint(); + methodId = '$didUri#${methodId.split('#').last}'; + + doc.addVerificationMethod( + DidVerificationMethod( + id: methodId, + type: vm.type, + controller: vm.controller, + publicKeyJwk: publicKey, + ), + ); + + for (final VerificationPurpose purpose in vm.purposes) { + doc.addVerificationPurpose(purpose, methodId); + } + } + + for (final DidService service in (services ?? [])) { + doc.addService(service); + } + + final BearerDid did = BearerDid( + uri: didUri, + keyManager: keyManager, + document: doc, + metadata: DidDocumentMetadata( + types: types, + ), + ); + + if (publish ?? true) { + DidDht.publish(did: did); + } + + return did; + } + + static String identityKeyToIdentifier({ + required Jwk identityKey, + }) { + // Convert the key from JWK format to a byte array. + final Uint8List publicKeyBytes = Crypto.publicKeyToBytes(identityKey); + + final String identifier = ZBase32.encode(publicKeyBytes); + return 'did:${DidDht.methodName}:$identifier'; + } + + static Future publish({ + required BearerDid did, + String? gatewayUri, + }) async { + // TODO: Finish publish method + // final DnsPacket dnsPacket = DnsPacket.fromDid(did); + } + static Future resolve( Did did, { String relayUrl = 'https://diddht.tbddev.org', diff --git a/packages/web5/lib/src/dids/did_dht/dns_packet/answer.dart b/packages/web5/lib/src/dids/did_dht/dns_packet/answer.dart index 9b33ecc..63e0481 100644 --- a/packages/web5/lib/src/dids/did_dht/dns_packet/answer.dart +++ b/packages/web5/lib/src/dids/did_dht/dns_packet/answer.dart @@ -8,6 +8,22 @@ import 'package:web5/src/dids/did_dht/dns_packet/consts.dart'; import 'package:web5/src/dids/did_dht/dns_packet/opt_data.dart'; import 'package:web5/src/dids/did_dht/dns_packet/txt_data.dart'; +class BaseAnswer { + BaseAnswer({ + required this.type, + required this.name, + this.ttl, + this.classType, + required this.data, + }); + + DnsType type; + String name; + int? ttl; + String? classType; + D data; +} + /// Represents an answer section in a DNS packet. class DnsAnswer { /// The domain name to which this resource record pertains. diff --git a/packages/web5/lib/src/dids/did_dht/dns_packet/header.dart b/packages/web5/lib/src/dids/did_dht/dns_packet/header.dart index 04356b9..18e19cb 100644 --- a/packages/web5/lib/src/dids/did_dht/dns_packet/header.dart +++ b/packages/web5/lib/src/dids/did_dht/dns_packet/header.dart @@ -4,20 +4,49 @@ import 'package:web5/src/dids/did_dht/dns_packet/opcode.dart'; import 'package:web5/src/dids/did_dht/dns_packet/rcode.dart'; class DnsHeader { + /// Identifier assigned by the program that generates the query. int id; + + /// Whether this message is a query (0), or a response (1) bool qr; + + /// Specifies kind of query in this message. DnsOpCode opcode; - bool aa; + + /// Specifies that the responding name server is an authority for the domain name in question section. + bool? aa; + + /// Specifies whether the message was truncated. bool tc; + + /// Directs the name server to pursue the query recursively bool rd; - bool ra; + + /// Set or cleared in a response, and denotes whether recursive query support is available in the name server + bool? ra; + + /// Reserved for future use, always set to 0. bool z; - bool ad; - bool cd; - DnsRCode rcode; + + /// TODO: Find documentation for this field + bool? ad; + + /// TODO: Find documentation for this field + bool? cd; + + /// Response code + DnsRCode? rcode; + + /// Number of entries in the question section. int qdcount; + + /// Number of resource records in the answer section. int ancount; + + /// Number of name server resource records in the authority records section. int nscount; + + /// Number of resource records in the additional records section. int arcount; get numQuestions => qdcount; @@ -31,14 +60,14 @@ class DnsHeader { required this.id, required this.qr, required this.opcode, - required this.aa, + this.aa, required this.tc, required this.rd, - required this.ra, - required this.z, - required this.ad, - required this.cd, - required this.rcode, + this.ra, + this.z = false, + this.ad, + this.cd, + this.rcode, required this.qdcount, required this.ancount, required this.nscount, diff --git a/packages/web5/lib/src/dids/did_dht/dns_packet/packet.dart b/packages/web5/lib/src/dids/did_dht/dns_packet/packet.dart index a3275e4..be0c846 100644 --- a/packages/web5/lib/src/dids/did_dht/dns_packet/packet.dart +++ b/packages/web5/lib/src/dids/did_dht/dns_packet/packet.dart @@ -1,8 +1,21 @@ +// ignore_for_file: constant_identifier_names + +import 'dart:convert'; import 'dart:typed_data'; +import 'package:web5/src/crypto.dart'; +import 'package:web5/src/dids/bearer_did.dart'; +import 'package:web5/src/dids/did_core/did_service.dart'; +import 'package:web5/src/dids/did_core/did_verification_method.dart'; import 'package:web5/src/dids/did_dht/dns_packet/answer.dart'; import 'package:web5/src/dids/did_dht/dns_packet/header.dart'; +import 'package:web5/src/dids/did_dht/dns_packet/opcode.dart'; import 'package:web5/src/dids/did_dht/dns_packet/question.dart'; +import 'package:web5/src/dids/did_dht/dns_packet/type.dart'; +import 'package:web5/src/dids/did_dht/registered_did_type.dart'; + +const int DNS_RECORD_TTL = 7200; +const int DID_DHT_SPECIFICATION_VERSION = 0; class DnsPacket { DnsHeader header; @@ -55,4 +68,212 @@ class DnsPacket { return DnsPacket(header: header, questions: questions, answers: answers); } + + factory DnsPacket.fromDid(BearerDid did) { + final List> dnsAnswerRecords = []; + final Map idLookup = {}; + final List serviceIds = []; + final List verificationMethodIds = []; + + // Add DNS TXT records if the DID document contains an `alsoKnownAs` property. + if (did.document.alsoKnownAs != null) { + dnsAnswerRecords.add( + BaseAnswer( + type: DnsType.TXT, + name: '_aka.did.', + ttl: DNS_RECORD_TTL, + data: did.document.alsoKnownAs!.join(','), + ), + ); + } + + // Add DNS TXT records if the DID document contains a `controller` property. + if (did.document.controller != null) { + String controller; + + if (did.document.controller is List) { + controller = (did.document.controller as List).join(','); + } else { + controller = did.document.controller as String; + } + + dnsAnswerRecords.add( + BaseAnswer( + type: DnsType.TXT, + name: '_cnt.did.', + ttl: DNS_RECORD_TTL, + data: controller, + ), + ); + } + + // Add DNS TXT records for each verification method. + if (did.document.verificationMethod != null) { + for (var i = 0; i < did.document.verificationMethod!.length; i++) { + final DidVerificationMethod vm = did.document.verificationMethod![i]; + + final String dnsRecordId = 'k$i'; + verificationMethodIds.add(dnsRecordId); + + // Remove fragment prefix, if any. + final String methodId = vm.id.split('#').last; + idLookup[methodId] = dnsRecordId; + + final Jwk? publicKey = vm.publicKeyJwk; + + // Use the public key's `crv` property to get the DID DHT key type. + int keyType; + + // Convert the public key from JWK format to a byte array. + Uint8List publicKeyBytes; + + switch (publicKey?.crv) { + case 'Ed25519': + keyType = 0; + publicKeyBytes = Ed25519.publicKeyToBytes(publicKey: publicKey!); + break; + case 'secp256k1': + keyType = 1; + publicKeyBytes = Secp256k1.publicKeyToBytes(publicKey: publicKey!); + break; + default: + throw 'Verification method ${vm.id} contains an unsupported key type: ${publicKey?.crv}'; + } + + // Convert the public key from a byte array to Base64URL format. + final String publicKeyBase64Url = utf8.decode(publicKeyBytes); + // Define the data for the DNS TXT record. + final List txtData = [ + 'id=$methodId', + 't=$keyType', + 'k=$publicKeyBase64Url', + ]; + + // Add the controller property, if set to a value other than the Identity Key (DID Subject). + if (vm.controller != did.document.id) txtData.add('c=${vm.controller}'); + + // Add a TXT record for the verification method. + dnsAnswerRecords.add( + BaseAnswer( + type: DnsType.TXT, + name: '_$dnsRecordId._did.', + ttl: DNS_RECORD_TTL, + data: txtData.join(';'), + ), + ); + } + } + + // Add DNS TXT records for each service. + if (did.document.service != null) { + for (var i = 0; i < did.document.service!.length; i++) { + final DidService service = did.document.service![i]; + final String dnsRecordId = 's$i'; + + serviceIds.add(dnsRecordId); + final String id = service.id.split('#').last; + + // Define the data for the DNS TXT record. + final List txtData = [ + 'id=$id', + 'se=${service.serviceEndpoint}', + 't=${service.type}', + ]; + + // Add a TXT record for the service. + dnsAnswerRecords.add( + BaseAnswer( + type: DnsType.TXT, + name: '_$dnsRecordId._did.', + data: txtData.join(';'), + ), + ); + } + } + + // Initialize the root DNS TXT record with the DID DHT specification version. + final List rootRecord = ['v=$DID_DHT_SPECIFICATION_VERSION']; + + // Add verification methods to the root record. + if (verificationMethodIds.isNotEmpty) { + rootRecord.add('vm=${verificationMethodIds.join(',')}'); + } + + // Collect the verification method IDs for the given relationship and add to the rootRecord. + if (did.document.assertionMethod?.isNotEmpty ?? false) { + rootRecord.add( + 'assertionMethod=${did.document.assertionMethod!.join(',')}', + ); + } + if (did.document.authentication?.isNotEmpty ?? false) { + rootRecord.add( + 'authentication=${did.document.authentication!.join(',')}', + ); + } + if (did.document.capabilityDelegation?.isNotEmpty ?? false) { + rootRecord.add( + 'capabilityDelegation=${did.document.capabilityDelegation!.join(',')}', + ); + } + if (did.document.capabilityInvocation?.isNotEmpty ?? false) { + rootRecord.add( + 'capabilityInvocation=${did.document.capabilityInvocation!.join(',')}', + ); + } + if (did.document.keyAgreement?.isNotEmpty ?? false) { + rootRecord.add( + 'keyAgreement=${did.document.keyAgreement!.join(',')}', + ); + } + + if (serviceIds.isNotEmpty) { + rootRecord.add('svc=${serviceIds.join(',')}'); + } + + // If defined, add a DNS TXT record for each registered DID type. + if (did.metadata.types?.isNotEmpty ?? false) { + dnsAnswerRecords.add( + BaseAnswer( + type: DnsType.TXT, + name: '_typ._did.', + ttl: DNS_RECORD_TTL, + data: 'id=${did.metadata.types!.map((e) => e.value).join(',')}', + ), + ); + } + + // Add a DNS TXT record for the root record. + dnsAnswerRecords.add( + BaseAnswer( + type: DnsType.TXT, + name: '_did.', + ttl: DNS_RECORD_TTL, + data: rootRecord.join(';'), + ), + ); + + // Per the DID DHT specification, the method-specific identifier must be appended as the + // Origin of all records. + final String identifier = did.document.id.split(':').last; + for (final BaseAnswer record in dnsAnswerRecords) { + record.name += identifier; + } + + // Create a DNS response packet with the authoritative answer flag set. + return DnsPacket( + header: DnsHeader( + id: 0, + qr: false, + opcode: DnsOpCode.QUERY, + tc: false, + rd: true, + qdcount: 0, + ancount: dnsAnswerRecords.length, + nscount: 0, + arcount: 0, + ), + questions: [], + answers: [], + ); + } } diff --git a/packages/web5/lib/src/dids/did_dht/registered_did_type.dart b/packages/web5/lib/src/dids/did_dht/registered_did_type.dart new file mode 100644 index 0000000..769c6dc --- /dev/null +++ b/packages/web5/lib/src/dids/did_dht/registered_did_type.dart @@ -0,0 +1,51 @@ +enum DidDhtRegisteredDidType { + /// Type 0 is reserved for DIDs that do not wish to associate themselves + /// with a specific type but wish to make themselves discoverable. + discoverable, + + /// Organization: https://schema.org/Organization + organization, + + /// Government Organization: https://schema.org/GovernmentOrganization + government, + + /// Corporation: https://schema.org/Corporation + corporation, + + /// Local Business: https://schema.org/LocalBusiness + localBusiness, + + /// Software Package: https://schema.org/SoftwareSourceCode + softwarePackage, + + /// Web App: https://schema.org/WebApplication + webApp, + + /// Financial Institution: https://schema.org/FinancialService + financialInstitution, +} + +extension IntegerValue on DidDhtRegisteredDidType { + int get value { + switch (this) { + case DidDhtRegisteredDidType.discoverable: + return 0; + case DidDhtRegisteredDidType.organization: + return 1; + case DidDhtRegisteredDidType.government: + return 2; + case DidDhtRegisteredDidType.corporation: + return 3; + case DidDhtRegisteredDidType.localBusiness: + return 4; + case DidDhtRegisteredDidType.softwarePackage: + return 5; + case DidDhtRegisteredDidType.webApp: + return 6; + case DidDhtRegisteredDidType.financialInstitution: + return 7; + default: + throw 'Unable to get value for DidDhtRegisteredDidType $this'; + } + } +} diff --git a/packages/web5/lib/src/encoders/zbase.dart b/packages/web5/lib/src/encoders/zbase.dart new file mode 100644 index 0000000..f0d7a1b --- /dev/null +++ b/packages/web5/lib/src/encoders/zbase.dart @@ -0,0 +1,53 @@ +class ZBase32 { + static const String _base32Alphabet = 'ybndrfg8ejkmcpqxot1uwisza345h769'; + + static String encode(List data) { + final StringBuffer result = StringBuffer(); + int bits = 0; + int bitCount = 0; + + for (int byte in data) { + bits = (bits << 8) | byte; + bitCount += 8; + + while (bitCount >= 5) { + bitCount -= 5; + final int index = bits >> bitCount; + bits &= ((1 << bitCount) - 1); + result.write(_base32Alphabet[index]); + } + } + + if (bitCount > 0) { + bits <<= (5 - bitCount); + result.write(_base32Alphabet[bits]); + } + + return result.toString(); + } + + static List decode(String encoded) { + final List result = []; + + int bits = 0; + int bitCount = 0; + + for (int i = 0; i < encoded.length; i++) { + final int value = _base32Alphabet.indexOf(encoded[i]); + if (value == -1) { + throw ArgumentError('Invalid character in encoded string'); + } + + bits = (bits << 5) | value; + bitCount += 5; + + if (bitCount >= 8) { + bitCount -= 8; + result.add(bits >> bitCount); + bits &= ((1 << bitCount) - 1); + } + } + + return result; + } +} diff --git a/packages/web5/pubspec.yaml b/packages/web5/pubspec.yaml index fe246f1..9c77071 100644 --- a/packages/web5/pubspec.yaml +++ b/packages/web5/pubspec.yaml @@ -7,7 +7,6 @@ environment: sdk: ^3.1.4 dependencies: - base32: ^2.1.3 collection: ^1.18.0 convert: ^3.1.1 cryptography: ^2.7.0