diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart index 884defba558d..0e369f46b234 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart @@ -7,6 +7,7 @@ export 'crypto/crypto_service.dart'; export 'crypto/key_derivation.dart'; export 'crypto/local_crypto_service.dart'; export 'dependency/dependency_provider.dart'; +export 'document/document_manager.dart'; export 'document/extension/document_list_sort_ext.dart'; export 'document/extension/document_map_to_list_ext.dart'; export 'document/identifiable.dart'; diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/document_manager.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/document_manager.dart new file mode 100644 index 000000000000..b5582446d4a2 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/document_manager.dart @@ -0,0 +1,83 @@ +import 'dart:typed_data'; + +import 'package:catalyst_compression/catalyst_compression.dart'; +import 'package:catalyst_cose/catalyst_cose.dart'; +import 'package:catalyst_key_derivation/catalyst_key_derivation.dart'; +import 'package:cbor/cbor.dart'; +import 'package:equatable/equatable.dart'; + +part 'document_manager_impl.dart'; + +/// Parses the document from the bytes obtained from [BinaryDocument.toBytes]. +/// +/// Usually this would convert the [bytes] into a [String], +/// decode a [String] into a json and then parse the data class +/// from the json representation. +typedef DocumentParser = T Function(Uint8List bytes); + +/// Manages the [SignedDocument]s. +abstract interface class DocumentManager { + /// The default constructor for the [DocumentManager], + /// provides the default implementation of the interface. + const factory DocumentManager() = _DocumentManagerImpl; + + /// Parses the document from the [bytes] representation. + /// + /// The [parser] must be able to parse the document + /// from the bytes produced by [BinaryDocument.toBytes]. + /// + /// The implementation of this method must be able to understand the [bytes] + /// that are obtained from the [SignedDocument.toBytes] method. + Future> parseDocument( + Uint8List bytes, { + required DocumentParser parser, + }); + + /// Signs the [document] with a single [privateKey]. + /// + /// The [publicKey] will be added as metadata in the signed document + /// so that it's easier to identify who signed it. + Future> signDocument( + T document, { + required Uint8List publicKey, + required Uint8List privateKey, + }); +} + +/// Represents an abstract document that is protected +/// with cryptographic signature. +/// +/// The [document] payload can be UTF-8 encoded bytes, a binary data +/// or anything else that can be represented in binary format. +abstract interface class SignedDocument { + /// The default constructor for the [SignedDocument]. + const SignedDocument(); + + /// A getter that returns a parsed document. + T get document; + + /// Verifies if the [document] has been signed by a private key + /// that belongs to the given [publicKey]. + Future verifySignature(Uint8List publicKey); + + /// Converts the document into binary representation. + Uint8List toBytes(); +} + +/// Represents an abstract document that can be represented in binary format. +// ignore: one_member_abstracts +abstract interface class BinaryDocument { + /// Converts the document into a binary representation. + /// + /// See [DocumentParser]. + Uint8List toBytes(); + + /// Returns the document content type. + DocumentContentType get contentType; +} + +/// Defines the content type of the [BinaryDocument]. +enum DocumentContentType { + /// The document's content type is JSON. + json, +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/document_manager_impl.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/document_manager_impl.dart new file mode 100644 index 000000000000..38682442e327 --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/document/document_manager_impl.dart @@ -0,0 +1,129 @@ +part of 'document_manager.dart'; + +const _brotliEncoding = StringValue(CoseValues.brotliContentEncoding); + +final class _DocumentManagerImpl implements DocumentManager { + const _DocumentManagerImpl(); + + @override + Future> parseDocument( + Uint8List bytes, { + required DocumentParser parser, + }) async { + final coseSign = CoseSign.fromCbor(cbor.decode(bytes)); + final payload = await _brotliDecompressPayload(coseSign); + final document = parser(payload); + return _CoseSignedDocument(coseSign, document); + } + + @override + Future> signDocument( + T document, { + required Uint8List publicKey, + required Uint8List privateKey, + }) async { + final compressedPayload = await _brotliCompressPayload(document.toBytes()); + + final coseSign = await CoseSign.sign( + protectedHeaders: CoseHeaders.protected( + contentEncoding: _brotliEncoding, + contentType: document.contentType.asCose, + ), + unprotectedHeaders: const CoseHeaders.unprotected(), + payload: compressedPayload, + signers: [_Bip32Ed25519XSigner(publicKey, privateKey)], + ); + + return _CoseSignedDocument(coseSign, document); + } + + Future _brotliCompressPayload(Uint8List payload) async { + final compressor = CatalystCompression.instance.brotli; + final compressed = await compressor.compress(payload); + return Uint8List.fromList(compressed); + } + + Future _brotliDecompressPayload(CoseSign coseSign) async { + if (coseSign.protectedHeaders.contentEncoding == _brotliEncoding) { + final compressor = CatalystCompression.instance.brotli; + final decompressed = await compressor.decompress(coseSign.payload); + return Uint8List.fromList(decompressed); + } else { + return coseSign.payload; + } + } +} + +final class _CoseSignedDocument + extends SignedDocument with EquatableMixin { + final CoseSign _coseSign; + + @override + final T document; + + const _CoseSignedDocument(this._coseSign, this.document); + + @override + Future verifySignature(Uint8List publicKey) async { + return _coseSign.verify( + verifier: _Bip32Ed25519XVerifier(publicKey), + ); + } + + @override + Uint8List toBytes() { + final bytes = cbor.encode(_coseSign.toCbor()); + return Uint8List.fromList(bytes); + } + + @override + List get props => [_coseSign, document]; +} + +extension _CoseDocumentContentType on DocumentContentType { + /// Maps the [DocumentContentType] into COSE representation. + StringOrInt get asCose { + switch (this) { + case DocumentContentType.json: + return const IntValue(CoseValues.jsonContentType); + } + } +} + +final class _Bip32Ed25519XSigner implements CatalystCoseSigner { + final Uint8List publicKey; + final Uint8List privateKey; + + const _Bip32Ed25519XSigner(this.publicKey, this.privateKey); + + @override + StringOrInt? get alg => const IntValue(CoseValues.eddsaAlg); + + @override + Future get kid async => publicKey; + + @override + Future sign(Uint8List data) async { + final pk = Bip32Ed25519XPrivateKeyFactory.instance.fromBytes(privateKey); + final signature = await pk.sign(data); + return Uint8List.fromList(signature.bytes); + } +} + +final class _Bip32Ed25519XVerifier implements CatalystCoseVerifier { + final Uint8List publicKey; + + const _Bip32Ed25519XVerifier(this.publicKey); + + @override + Future get kid async => publicKey; + + @override + Future verify(Uint8List data, Uint8List signature) async { + final pk = Bip32Ed25519XPublicKeyFactory.instance.fromBytes(publicKey); + return pk.verify( + data, + signature: Bip32Ed25519XSignatureFactory.instance.fromBytes(signature), + ); + } +} diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/pubspec.yaml b/catalyst_voices/packages/internal/catalyst_voices_shared/pubspec.yaml index 56bdf864fa96..14da899f7d65 100644 --- a/catalyst_voices/packages/internal/catalyst_voices_shared/pubspec.yaml +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/pubspec.yaml @@ -9,9 +9,12 @@ environment: dependencies: catalyst_cardano_serialization: ^0.4.0 + catalyst_compression: ^0.3.0 + catalyst_cose: ^0.3.0 catalyst_key_derivation: ^0.1.0 catalyst_voices_models: path: ../catalyst_voices_models + cbor: ^6.2.0 collection: ^1.18.0 convert: ^3.1.1 cryptography: ^2.7.0 diff --git a/catalyst_voices/packages/internal/catalyst_voices_shared/test/src/document/document_manager_test.dart b/catalyst_voices/packages/internal/catalyst_voices_shared/test/src/document/document_manager_test.dart new file mode 100644 index 000000000000..40b1e7ff4c6d --- /dev/null +++ b/catalyst_voices/packages/internal/catalyst_voices_shared/test/src/document/document_manager_test.dart @@ -0,0 +1,172 @@ +import 'dart:convert'; + +import 'package:catalyst_compression/catalyst_compression.dart'; +import 'package:catalyst_key_derivation/catalyst_key_derivation.dart'; +import 'package:catalyst_voices_shared/src/document/document_manager.dart'; +import 'package:convert/convert.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final _privateKey = Uint8List.fromList(List.filled(32, 0)); +final _publicKey = Uint8List.fromList(List.filled(32, 1)); +final _signature = Uint8List.fromList(List.filled(32, 2)); + +void main() { + group(DocumentManager, () { + const documentManager = DocumentManager(); + + setUpAll(() { + CatalystCompressionPlatform.instance = _FakeCatalystCompressionPlatform(); + + Bip32Ed25519XPublicKeyFactory.instance = + _FakeBip32Ed25519XPublicKeyFactory(); + + Bip32Ed25519XPrivateKeyFactory.instance = + _FakeBip32Ed25519XPrivateKeyFactory(); + + Bip32Ed25519XSignatureFactory.instance = + _FakeBip32Ed25519XSignatureFactory(); + }); + + test( + 'signDocument creates a signed document ' + 'that can be converted from/to bytes', () async { + const document = _JsonDocument('title'); + + final signedDocument = await documentManager.signDocument( + document, + publicKey: _publicKey, + privateKey: _privateKey, + ); + + expect(signedDocument.document, equals(document)); + + final isVerified = await signedDocument.verifySignature(_publicKey); + expect(isVerified, isTrue); + + final signedDocumentBytes = signedDocument.toBytes(); + final parsedDocument = await documentManager.parseDocument( + signedDocumentBytes, + parser: _JsonDocument.fromBytes, + ); + + expect(parsedDocument, equals(signedDocument)); + }); + }); +} + +final class _JsonDocument extends Equatable implements BinaryDocument { + final String title; + + const _JsonDocument(this.title); + + factory _JsonDocument.fromBytes(Uint8List bytes) { + final string = utf8.decode(bytes); + final map = json.decode(string); + return _JsonDocument.fromJson(map as Map); + } + + factory _JsonDocument.fromJson(Map map) { + return _JsonDocument(map['title'] as String); + } + + Map toJson() { + return {'title': title}; + } + + @override + Uint8List toBytes() { + final jsonString = json.encode(toJson()); + return utf8.encode(jsonString); + } + + @override + DocumentContentType get contentType => DocumentContentType.json; + + @override + List get props => [title]; +} + +class _FakeCatalystCompressionPlatform extends CatalystCompressionPlatform { + @override + CatalystCompressor get brotli => const _FakeCompressor(); +} + +final class _FakeCompressor implements CatalystCompressor { + const _FakeCompressor(); + + @override + Future> compress(List bytes) async => bytes; + + @override + Future> decompress(List bytes) async => bytes; +} + +class _FakeBip32Ed25519XPublicKeyFactory extends Bip32Ed25519XPublicKeyFactory { + @override + Bip32Ed25519XPublicKey fromBytes(List bytes) { + return _FakeBip32Ed22519XPublicKey(bytes: bytes); + } +} + +class _FakeBip32Ed25519XPrivateKeyFactory + extends Bip32Ed25519XPrivateKeyFactory { + @override + Bip32Ed25519XPrivateKey fromBytes(List bytes) { + return _FakeBip32Ed22519XPrivateKey(bytes: bytes); + } +} + +class _FakeBip32Ed25519XSignatureFactory extends Bip32Ed25519XSignatureFactory { + @override + Bip32Ed25519XSignature fromBytes(List bytes) { + return _FakeBip32Ed22519XSignature(bytes: bytes); + } +} + +class _FakeBip32Ed22519XPublicKey extends Fake + implements Bip32Ed25519XPublicKey { + @override + final List bytes; + + _FakeBip32Ed22519XPublicKey({required this.bytes}); + + @override + Future verify( + List message, { + required Bip32Ed25519XSignature signature, + }) async { + return listEquals(signature.bytes, _signature); + } + + @override + String toHex() => hex.encode(bytes); +} + +class _FakeBip32Ed22519XPrivateKey extends Fake + implements Bip32Ed25519XPrivateKey { + @override + final List bytes; + + _FakeBip32Ed22519XPrivateKey({required this.bytes}); + + @override + Future sign(List message) async { + return _FakeBip32Ed22519XSignature(bytes: _signature); + } + + @override + String toHex() => hex.encode(bytes); +} + +class _FakeBip32Ed22519XSignature extends Fake + implements Bip32Ed25519XSignature { + @override + final List bytes; + + _FakeBip32Ed22519XSignature({required this.bytes}); + + @override + String toHex() => hex.encode(bytes); +} diff --git a/catalyst_voices/packages/libs/catalyst_cose/README.md b/catalyst_voices/packages/libs/catalyst_cose/README.md index ee107be4752f..90915e7da43b 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/README.md +++ b/catalyst_voices/packages/libs/catalyst_cose/README.md @@ -47,14 +47,10 @@ Future main() async { Future _coseSign1() async { final algorithm = Ed25519(); final keyPair = await algorithm.newKeyPairFromSeed(List.filled(32, 0)); - final publicKey = await keyPair.extractPublicKey().then((e) => e.bytes); final signerVerifier = _SignerVerifier(algorithm, keyPair); final coseSign1 = await CoseSign1.sign( - protectedHeaders: CoseHeaders.protected( - alg: const IntValue(CoseValues.eddsaAlg), - kid: hex.encode(publicKey), - ), + protectedHeaders: const CoseHeaders.protected(), unprotectedHeaders: const CoseHeaders.unprotected(), signer: signerVerifier, payload: utf8.encode('This is the content.'), @@ -78,20 +74,16 @@ Future _coseSign1() async { Future _coseSign() async { final algorithm = Ed25519(); final keyPair = await algorithm.newKeyPairFromSeed(List.filled(32, 0)); - final publicKey = await keyPair.extractPublicKey().then((e) => e.bytes); final signerVerifier = _SignerVerifier(algorithm, keyPair); final coseSign = await CoseSign.sign( - protectedHeaders: CoseHeaders.protected( - alg: const IntValue(CoseValues.eddsaAlg), - kid: hex.encode(publicKey), - ), + protectedHeaders: const CoseHeaders.protected(), unprotectedHeaders: const CoseHeaders.unprotected(), signers: [signerVerifier], payload: utf8.encode('This is the content.'), ); - final verified = await coseSign.verify( + final verified = await coseSign.verifyAll( verifiers: [signerVerifier], ); @@ -113,6 +105,15 @@ final class _SignerVerifier const _SignerVerifier(this._algorithm, this._keyPair); + @override + StringOrInt? get alg => const IntValue(CoseValues.eddsaAlg); + + @override + Future get kid async { + final pk = await _keyPair.extractPublicKey(); + return Uint8List.fromList(pk.bytes); + } + @override Future sign(Uint8List data) async { final signature = await _algorithm.sign(data, keyPair: _keyPair); diff --git a/catalyst_voices/packages/libs/catalyst_cose/example/main.dart b/catalyst_voices/packages/libs/catalyst_cose/example/main.dart index c7ea8131a662..3bed84f27249 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/example/main.dart +++ b/catalyst_voices/packages/libs/catalyst_cose/example/main.dart @@ -16,14 +16,10 @@ Future main() async { Future _coseSign1() async { final algorithm = Ed25519(); final keyPair = await algorithm.newKeyPairFromSeed(List.filled(32, 0)); - final publicKey = await keyPair.extractPublicKey().then((e) => e.bytes); final signerVerifier = _SignerVerifier(algorithm, keyPair); final coseSign1 = await CoseSign1.sign( - protectedHeaders: CoseHeaders.protected( - alg: const IntValue(CoseValues.eddsaAlg), - kid: hex.encode(publicKey), - ), + protectedHeaders: const CoseHeaders.protected(), unprotectedHeaders: const CoseHeaders.unprotected(), signer: signerVerifier, payload: utf8.encode('This is the content.'), @@ -47,20 +43,16 @@ Future _coseSign1() async { Future _coseSign() async { final algorithm = Ed25519(); final keyPair = await algorithm.newKeyPairFromSeed(List.filled(32, 0)); - final publicKey = await keyPair.extractPublicKey().then((e) => e.bytes); final signerVerifier = _SignerVerifier(algorithm, keyPair); final coseSign = await CoseSign.sign( - protectedHeaders: CoseHeaders.protected( - alg: const IntValue(CoseValues.eddsaAlg), - kid: hex.encode(publicKey), - ), + protectedHeaders: const CoseHeaders.protected(), unprotectedHeaders: const CoseHeaders.unprotected(), signers: [signerVerifier], payload: utf8.encode('This is the content.'), ); - final verified = await coseSign.verify( + final verified = await coseSign.verifyAll( verifiers: [signerVerifier], ); @@ -82,6 +74,15 @@ final class _SignerVerifier const _SignerVerifier(this._algorithm, this._keyPair); + @override + StringOrInt? get alg => const IntValue(CoseValues.eddsaAlg); + + @override + Future get kid async { + final pk = await _keyPair.extractPublicKey(); + return Uint8List.fromList(pk.bytes); + } + @override Future sign(Uint8List data) async { final signature = await _algorithm.sign(data, keyPair: _keyPair); diff --git a/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_constants.dart b/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_constants.dart index feec000ce264..a914fd495292 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_constants.dart +++ b/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_constants.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; +import 'package:catalyst_cose/src/types/string_or_int.dart'; import 'package:cbor/cbor.dart'; /// Holds commonly used tags in COSE. @@ -62,7 +63,7 @@ final class CoseValues { static const eddsaAlg = -8; /// The json content type value. - static const jsonContentType = 30; + static const jsonContentType = 50; /// The brotli compression content encoding. static const brotliContentEncoding = 'br'; @@ -71,6 +72,14 @@ final class CoseValues { /// The interface for the data signer callback. // ignore: one_member_abstracts abstract interface class CatalystCoseSigner { + /// Returns the alg identifier that should refer + /// to the cryptographic algorithm used to [sign] the data. + StringOrInt? get alg; + + /// Returns a key identifier that typically should refer to the public key + /// of the private key used to sign the data. + Future get kid; + /// The [data] should be signed with a private key /// and the resulting signature returned as [Uint8List]. Future sign(Uint8List data); @@ -79,6 +88,10 @@ abstract interface class CatalystCoseSigner { /// The interface for the signature verifier callback. // ignore: one_member_abstracts abstract interface class CatalystCoseVerifier { + /// Returns a key identifier that typically should refer to the public key + /// of the private key used to sign the data. + Future get kid; + /// The [signature] should be verified against /// a known public/private key over the [data]. Future verify(Uint8List data, Uint8List signature); diff --git a/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_sign.dart b/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_sign.dart index e525fe941d87..7d73a4eaceca 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_sign.dart +++ b/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_sign.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:catalyst_cose/src/cose_constants.dart'; import 'package:catalyst_cose/src/types/cose_headers.dart'; import 'package:cbor/cbor.dart'; +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; /// The COSE_SIGN structure implementation, supporting multiple signatures. @@ -34,9 +35,7 @@ final class CoseSign extends Equatable { /// Deserializes the type from cbor. factory CoseSign.fromCbor(CborValue value) { - if (value is! CborList || - value.length != 4 || - !value.tags.contains(CoseTags.coseSign)) { + if (value is! CborList || value.length != 4) { throw FormatException('$value is not a valid COSE_SIGN structure'); } @@ -72,21 +71,14 @@ final class CoseSign extends Equatable { final signatures = []; for (final signer in signers) { final signatureProtectedHeaders = CoseHeaders.protected( - alg: protectedHeaders.alg, + alg: signer.alg, + kid: await signer.kid, ); - final sigStructure = _createCoseSignSigStructure( - bodyProtectedHeaders: protectedHeaders.toCbor(), - signatureProtectedHeaders: signatureProtectedHeaders.toCbor(), - payload: CborBytes(payload), - ); - - final toBeSigned = Uint8List.fromList( - cbor.encode( - CborBytes( - cbor.encode(sigStructure), - ), - ), + final toBeSigned = _createCoseSignSigStructureBytes( + bodyProtectedHeaders: protectedHeaders, + signatureProtectedHeaders: signatureProtectedHeaders, + payload: payload, ); final signature = CoseSignature( @@ -106,46 +98,37 @@ final class CoseSign extends Equatable { ); } - /// Verifies whether the COSE_SIGN [signatures] are valid. + /// Verifies whether the COSE_SIGN signature is valid. /// - /// The [verifiers] are responsible for providing the verification algorithm. - /// The count of [verifiers] must match the number of [signatures]. + /// The signature is selected from the list of [signatures] based on the kid]. + /// The [verifier] is responsible for providing the verification algorithm. Future verify({ - required List verifiers, + required CatalystCoseVerifier verifier, }) async { - if (verifiers.length != signatures.length) { - throw ArgumentError( - 'Number of verifiers must match the number of signatures.', - ); + for (final signature in signatures) { + if (const DeepCollectionEquality() + .equals(signature.protectedHeaders.kid, await verifier.kid)) { + final toBeSigned = _createCoseSignSigStructureBytes( + bodyProtectedHeaders: protectedHeaders, + signatureProtectedHeaders: signature.protectedHeaders, + payload: payload, + ); + return verifier.verify(toBeSigned, signature.signature); + } } - for (var i = 0; i < verifiers.length; i++) { - final verifier = verifiers[i]; - final signature = signatures[i]; - - final signatureProtectedHeaders = CoseHeaders.protected( - alg: protectedHeaders.alg, - ); - - final sigStructure = _createCoseSignSigStructure( - bodyProtectedHeaders: protectedHeaders.toCbor(), - signatureProtectedHeaders: signatureProtectedHeaders.toCbor(), - payload: CborBytes(payload), - ); - - final toBeSigned = Uint8List.fromList( - cbor.encode( - CborBytes( - cbor.encode(sigStructure), - ), - ), - ); - - final isVerified = await verifier.verify( - Uint8List.fromList(toBeSigned), - Uint8List.fromList(signature.signature), - ); + // no eligible signature found that would match the kid + return false; + } + /// Verifies whether the COSE_SIGN [signatures] are valid. + /// + /// The [verifiers] are responsible for providing the verification algorithm. + Future verifyAll({ + required List verifiers, + }) async { + for (final verifier in verifiers) { + final isVerified = await verify(verifier: verifier); if (!isVerified) { return false; } @@ -177,6 +160,26 @@ final class CoseSign extends Equatable { signatures, ]; + static Uint8List _createCoseSignSigStructureBytes({ + required CoseHeaders bodyProtectedHeaders, + required CoseHeaders signatureProtectedHeaders, + required Uint8List payload, + }) { + final sigStructure = _createCoseSignSigStructure( + bodyProtectedHeaders: bodyProtectedHeaders.toCbor(), + signatureProtectedHeaders: signatureProtectedHeaders.toCbor(), + payload: CborBytes(payload), + ); + + return Uint8List.fromList( + cbor.encode( + CborBytes( + cbor.encode(sigStructure), + ), + ), + ); + } + static CborList _createCoseSignSigStructure({ required CborValue bodyProtectedHeaders, required CborValue signatureProtectedHeaders, diff --git a/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_sign1.dart b/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_sign1.dart index 543494882424..842fa57ef5f9 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_sign1.dart +++ b/catalyst_voices/packages/libs/catalyst_cose/lib/src/cose_sign1.dart @@ -32,9 +32,7 @@ final class CoseSign1 extends Equatable { /// Deserializes the type from cbor. factory CoseSign1.fromCbor(CborValue value) { - if (value is! CborList || - value.length != 4 || - !value.tags.contains(CoseTags.coseSign1)) { + if (value is! CborList || value.length != 4) { throw FormatException('$value is not a valid COSE_SIGN1 structure'); } @@ -67,6 +65,13 @@ final class CoseSign1 extends Equatable { required Uint8List payload, required CatalystCoseSigner signer, }) async { + final kid = await signer.kid; + + protectedHeaders = protectedHeaders.copyWith( + alg: () => signer.alg, + kid: () => kid, + ); + final sigStructure = _createCoseSign1SigStructure( protectedHeader: protectedHeaders.toCbor(), payload: CborBytes(payload), diff --git a/catalyst_voices/packages/libs/catalyst_cose/lib/src/types/cose_headers.dart b/catalyst_voices/packages/libs/catalyst_cose/lib/src/types/cose_headers.dart index dbc93956577f..ff1da58c1133 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/lib/src/types/cose_headers.dart +++ b/catalyst_voices/packages/libs/catalyst_cose/lib/src/types/cose_headers.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:catalyst_cose/src/cose_constants.dart'; import 'package:catalyst_cose/src/types/string_or_int.dart'; import 'package:catalyst_cose/src/types/uuid.dart'; @@ -5,14 +7,27 @@ import 'package:catalyst_cose/src/utils/cbor_utils.dart'; import 'package:cbor/cbor.dart'; import 'package:equatable/equatable.dart'; +/// A callback to get an optional value. +/// Helps to distinguish whether a method argument +/// has been passed as null or not passed at all. +/// +/// See [CoseHeaders.copyWith]. +typedef OptionalValueGetter = T? Function(); + /// A class that specifies headers that /// can be used in protected/unprotected COSE headers. final class CoseHeaders extends Equatable { /// See [CoseHeaderKeys.alg]. + /// + /// Do not set the [alg] directly in the headers, + /// it will be auto-populated with [CatalystCoseSigner.alg] value. final StringOrInt? alg; /// See [CoseHeaderKeys.kid]. - final String? kid; + /// + /// Do not set the [kid] directly in the headers, + /// it will be auto-populated with [CatalystCoseSigner.kid] value. + final Uint8List? kid; /// See [CoseHeaderKeys.contentType]. final StringOrInt? contentType; @@ -116,7 +131,7 @@ final class CoseHeaders extends Equatable { return CoseHeaders( alg: CborUtils.deserializeStringOrInt(map[CoseHeaderKeys.alg]), - kid: CborUtils.deserializeString(map[CoseHeaderKeys.kid]), + kid: CborUtils.deserializeBytes(map[CoseHeaderKeys.kid]), contentType: CborUtils.deserializeStringOrInt(map[CoseHeaderKeys.contentType]), contentEncoding: @@ -138,7 +153,7 @@ final class CoseHeaders extends Equatable { CborValue toCbor() { final map = CborMap({ if (alg != null) CoseHeaderKeys.alg: alg!.toCbor(), - if (kid != null) CoseHeaderKeys.kid: CborString(kid!), + if (kid != null) CoseHeaderKeys.kid: CborBytes(kid!), if (contentType != null) CoseHeaderKeys.contentType: contentType!.toCbor(), if (contentEncoding != null) @@ -161,6 +176,40 @@ final class CoseHeaders extends Equatable { } } + /// Returns a copy of the [CoseHeaders] with overwritten properties. + CoseHeaders copyWith({ + OptionalValueGetter? alg, + OptionalValueGetter? kid, + OptionalValueGetter? contentType, + OptionalValueGetter? contentEncoding, + OptionalValueGetter? type, + OptionalValueGetter? id, + OptionalValueGetter? ver, + OptionalValueGetter? ref, + OptionalValueGetter? template, + OptionalValueGetter? reply, + OptionalValueGetter? section, + OptionalValueGetter?>? collabs, + bool? encodeAsBytes, + }) { + return CoseHeaders( + alg: alg != null ? alg() : this.alg, + kid: kid != null ? kid() : this.kid, + contentType: contentType != null ? contentType() : this.contentType, + contentEncoding: + contentEncoding != null ? contentEncoding() : this.contentEncoding, + type: type != null ? type() : this.type, + id: id != null ? id() : this.id, + ver: ver != null ? ver() : this.ver, + ref: ref != null ? ref() : this.ref, + template: template != null ? template() : this.template, + reply: reply != null ? reply() : this.reply, + section: section != null ? section() : this.section, + collabs: collabs != null ? collabs() : this.collabs, + encodeAsBytes: encodeAsBytes ?? this.encodeAsBytes, + ); + } + @override List get props => [ alg, diff --git a/catalyst_voices/packages/libs/catalyst_cose/lib/src/types/uuid.dart b/catalyst_voices/packages/libs/catalyst_cose/lib/src/types/uuid.dart index 927a7ce35409..14ee6c340978 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/lib/src/types/uuid.dart +++ b/catalyst_voices/packages/libs/catalyst_cose/lib/src/types/uuid.dart @@ -10,7 +10,7 @@ import 'package:uuid/uuid.dart' as uuid; extension type const Uuid(String value) { /// Deserializes the type from cbor. factory Uuid.fromCbor(CborValue value) { - if (value is CborBytes && value.tags.contains(CborUtils.uuidTag)) { + if (value is CborBytes) { return Uuid(uuid.Uuid.unparse(value.bytes)); } else { throw FormatException('The $value is not a valid uuid!'); @@ -24,80 +24,51 @@ extension type const Uuid(String value) { } } -/// Either a single [Uuid] or two [Uuid]'s. +/// A reference to an entity represented by the [id]. +/// Optionally the version of the entity may be specified by the [ver]. /// /// What this uuid means depends where and how the class is used. /// In CDDL it is defined as (UUID / [UUID, UUID]). -sealed class ReferenceUuid extends Equatable { +final class ReferenceUuid extends Equatable { + /// The referenced entity uuid. + final Uuid id; + + /// The version of the referenced entity. + final Uuid? ver; + /// The default constructor for the [ReferenceUuid]. - const ReferenceUuid(); + const ReferenceUuid({ + required this.id, + this.ver, + }); /// Deserializes the type from cbor. factory ReferenceUuid.fromCbor(CborValue value) { if (value is CborList) { - return DoubleReferenceUuid.fromCbor(value); + return ReferenceUuid( + id: Uuid.fromCbor(value[0]), + ver: Uuid.fromCbor(value[1]), + ); } else { - return SingleReferenceUuid.fromCbor(value); + return ReferenceUuid( + id: Uuid.fromCbor(value), + ); } } /// Serializes the type as cbor. - CborValue toCbor(); -} - -/// A single [Uuid] reference. -final class SingleReferenceUuid extends ReferenceUuid { - /// A single reference uuid. - final Uuid uuid; - - /// A default constructor for the [SingleReferenceUuid]. - const SingleReferenceUuid(this.uuid); - - /// Deserializes the type from cbor. - factory SingleReferenceUuid.fromCbor(CborValue value) { - return SingleReferenceUuid(Uuid.fromCbor(value)); - } - - @override CborValue toCbor() { - return uuid.toCbor(); - } - - @override - List get props => [uuid]; -} - -/// A single [Uuid] reference. -final class DoubleReferenceUuid extends ReferenceUuid { - /// The first referenced uuid. - final Uuid first; - - /// The second reference uuid. - final Uuid second; - - /// A default constructor for the [DoubleReferenceUuid]. - const DoubleReferenceUuid(this.first, this.second); - - /// Deserializes the type from cbor. - factory DoubleReferenceUuid.fromCbor(CborValue value) { - if (value is! CborList || value.length != 2) { - throw FormatException('Wrong format of the DoubleReferenceUuid: $value'); + final ver = this.ver; + if (ver != null) { + return CborList([ + id.toCbor(), + ver.toCbor(), + ]); + } else { + return id.toCbor(); } - - return DoubleReferenceUuid( - Uuid.fromCbor(value[0]), - Uuid.fromCbor(value[1]), - ); - } - - @override - CborValue toCbor() { - return CborList([ - first.toCbor(), - second.toCbor(), - ]); } @override - List get props => [first, second]; + List get props => [id, ver]; } diff --git a/catalyst_voices/packages/libs/catalyst_cose/lib/src/utils/cbor_utils.dart b/catalyst_voices/packages/libs/catalyst_cose/lib/src/utils/cbor_utils.dart index a3e5b9f80fe6..969b38f545c0 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/lib/src/utils/cbor_utils.dart +++ b/catalyst_voices/packages/libs/catalyst_cose/lib/src/utils/cbor_utils.dart @@ -1,3 +1,6 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:catalyst_cose/src/types/string_or_int.dart'; import 'package:catalyst_cose/src/types/uuid.dart'; import 'package:cbor/cbor.dart'; @@ -36,6 +39,19 @@ final class CborUtils { return ReferenceUuid.fromCbor(value); } + /// Deserializes optional [Uint8List] type. + static Uint8List? deserializeBytes(CborValue? value) { + if (value == null) { + return null; + } + + if (value is CborString) { + return utf8.encode(value.toString()); + } + + return Uint8List.fromList((value as CborBytes).bytes); + } + /// Deserializes optional [String] type. static String? deserializeString(CborValue? value) { if (value == null) { diff --git a/catalyst_voices/packages/libs/catalyst_cose/pubspec.yaml b/catalyst_voices/packages/libs/catalyst_cose/pubspec.yaml index 9f4b358988e6..6bebbcbdc825 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/pubspec.yaml +++ b/catalyst_voices/packages/libs/catalyst_cose/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: cbor: ^6.2.0 + collection: ^1.18.0 convert: ^3.1.1 cryptography: ^2.7.0 equatable: ^2.0.7 diff --git a/catalyst_voices/packages/libs/catalyst_cose/test/cose_sign1_test.dart b/catalyst_voices/packages/libs/catalyst_cose/test/cose_sign1_test.dart index 2273a8c0d360..83d879725751 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/test/cose_sign1_test.dart +++ b/catalyst_voices/packages/libs/catalyst_cose/test/cose_sign1_test.dart @@ -2,45 +2,44 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:catalyst_cose/catalyst_cose.dart'; -import 'package:convert/convert.dart'; import 'package:cryptography/cryptography.dart'; import 'package:test/test.dart'; void main() { group(CoseSign1, () { const uuidV7 = '0193b535-7196-7cd1-84e6-ad9c316cf2d2'; - late SimplePublicKey publicKey; late _SignerVerifier signerVerifier; setUp(() async { // Initialize the Ed25519 algorithm and generate a key pair final algorithm = Ed25519(); final keyPair = await algorithm.newKeyPairFromSeed(List.filled(32, 0)); - publicKey = await keyPair.extractPublicKey(); signerVerifier = _SignerVerifier(algorithm, keyPair); }); test('sign generates a valid COSE_SIGN1 structure', () async { final coseSign1 = await CoseSign1.sign( - protectedHeaders: CoseHeaders.protected( - alg: const IntValue(CoseValues.eddsaAlg), - kid: hex.encode(publicKey.bytes), - contentType: const IntValue(CoseValues.jsonContentType), - contentEncoding: const StringValue(CoseValues.brotliContentEncoding), - type: const Uuid(uuidV7), - id: const Uuid(uuidV7), - ver: const Uuid(uuidV7), - ref: const SingleReferenceUuid(Uuid(uuidV7)), - template: const SingleReferenceUuid(Uuid(uuidV7)), - reply: const SingleReferenceUuid(Uuid(uuidV7)), + protectedHeaders: const CoseHeaders.protected( + contentType: IntValue(CoseValues.jsonContentType), + contentEncoding: StringValue(CoseValues.brotliContentEncoding), + type: Uuid(uuidV7), + id: Uuid(uuidV7), + ver: Uuid(uuidV7), + ref: ReferenceUuid(id: Uuid(uuidV7)), + template: ReferenceUuid(id: Uuid(uuidV7)), + reply: ReferenceUuid(id: Uuid(uuidV7)), section: 'section_name', - collabs: const ['test@domain.com'], + collabs: ['test@domain.com'], ), unprotectedHeaders: const CoseHeaders.unprotected(), signer: signerVerifier, payload: utf8.encode('Test payload'), ); + // verify whether alg & kid fields were added to protected headers + expect(coseSign1.protectedHeaders.alg, isNotNull); + expect(coseSign1.protectedHeaders.kid, isNotEmpty); + // test whether signature is valid final isValid = await coseSign1.verify(verifier: signerVerifier); expect(isValid, isTrue); @@ -59,7 +58,7 @@ void main() { signature: Uint8List(64), ); - // test whether signature is valid + // test whether signature is invalid final isValid = await coseSign1.verify(verifier: signerVerifier); expect(isValid, isFalse); }); @@ -73,6 +72,15 @@ final class _SignerVerifier const _SignerVerifier(this._algorithm, this._keyPair); + @override + StringOrInt? get alg => const IntValue(CoseValues.eddsaAlg); + + @override + Future get kid async { + final pk = await _keyPair.extractPublicKey(); + return Uint8List.fromList(pk.bytes); + } + @override Future sign(Uint8List data) async { final signature = await _algorithm.sign(data, keyPair: _keyPair); diff --git a/catalyst_voices/packages/libs/catalyst_cose/test/cose_sign_test.dart b/catalyst_voices/packages/libs/catalyst_cose/test/cose_sign_test.dart index 296415509338..2acf3925f78d 100644 --- a/catalyst_voices/packages/libs/catalyst_cose/test/cose_sign_test.dart +++ b/catalyst_voices/packages/libs/catalyst_cose/test/cose_sign_test.dart @@ -2,47 +2,52 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:catalyst_cose/catalyst_cose.dart'; -import 'package:convert/convert.dart'; import 'package:cryptography/cryptography.dart'; import 'package:test/test.dart'; void main() { group(CoseSign, () { + const uuidV4 = 'e9aba14f-d05b-49b2-b5b5-100595853384'; const uuidV7 = '0193b535-7196-7cd1-84e6-ad9c316cf2d2'; - late SimplePublicKey publicKey; late _SignerVerifier signerVerifier; setUp(() async { // Initialize the Ed25519 algorithm and generate a key pair final algorithm = Ed25519(); final keyPair = await algorithm.newKeyPairFromSeed(List.filled(32, 0)); - publicKey = await keyPair.extractPublicKey(); signerVerifier = _SignerVerifier(algorithm, keyPair); }); test('sign generates a valid COSE_SIGN structure', () async { final coseSign = await CoseSign.sign( - protectedHeaders: CoseHeaders.protected( - alg: const IntValue(CoseValues.eddsaAlg), - kid: hex.encode(publicKey.bytes), - contentType: const IntValue(CoseValues.jsonContentType), - contentEncoding: const StringValue(CoseValues.brotliContentEncoding), - type: const Uuid(uuidV7), - id: const Uuid(uuidV7), - ver: const Uuid(uuidV7), - ref: const SingleReferenceUuid(Uuid(uuidV7)), - template: const SingleReferenceUuid(Uuid(uuidV7)), - reply: const SingleReferenceUuid(Uuid(uuidV7)), + protectedHeaders: const CoseHeaders.protected( + contentType: IntValue(CoseValues.jsonContentType), + contentEncoding: StringValue(CoseValues.brotliContentEncoding), + type: Uuid(uuidV4), + id: Uuid(uuidV7), + ver: Uuid(uuidV7), + ref: ReferenceUuid(id: Uuid(uuidV7)), + template: ReferenceUuid(id: Uuid(uuidV7)), + reply: ReferenceUuid(id: Uuid(uuidV7)), section: 'section_name', - collabs: const ['test@domain.com'], + collabs: ['test@domain.com'], ), unprotectedHeaders: const CoseHeaders.unprotected(), signers: [signerVerifier], payload: utf8.encode('Test payload'), ); - // test whether signature is valid - final isValid = await coseSign.verify(verifiers: [signerVerifier]); + for (final signature in coseSign.signatures) { + // verify whether alg & kid fields were added to protected headers + expect(signature.protectedHeaders.alg, isNotNull); + expect(signature.protectedHeaders.kid, isNotEmpty); + } + + // test whether signatures are valid + final isValidAll = await coseSign.verifyAll(verifiers: [signerVerifier]); + expect(isValidAll, isTrue); + + final isValid = await coseSign.verify(verifier: signerVerifier); expect(isValid, isTrue); // test whether serialization/deserialization works @@ -65,8 +70,12 @@ void main() { ], ); - // test whether signature is valid - final isValid = await coseSign.verify(verifiers: [signerVerifier]); + // test whether all signatures are invalid + final isValidAll = await coseSign.verifyAll(verifiers: [signerVerifier]); + expect(isValidAll, isFalse); + + // test whether all signatures are invalid + final isValid = await coseSign.verify(verifier: signerVerifier); expect(isValid, isFalse); }); }); @@ -79,6 +88,15 @@ final class _SignerVerifier const _SignerVerifier(this._algorithm, this._keyPair); + @override + StringOrInt? get alg => const IntValue(CoseValues.eddsaAlg); + + @override + Future get kid async { + final pk = await _keyPair.extractPublicKey(); + return Uint8List.fromList(pk.bytes); + } + @override Future sign(Uint8List data) async { final signature = await _algorithm.sign(data, keyPair: _keyPair);