Skip to content

Commit 14c01e4

Browse files
authored
feat(cat-voices): encode/decode cose documents (#1408)
* feat: addd melos build_runner_repository to justfile * feat: update cose sign to support multiple different signatures and algs * feat: add document manager that handles signed documents (COSE_SIGN) * fix: kid should be encoded as Uint8List, not as string * style: typo * fix: collection equality * chore: review feedback * feat: add content type * chore: rename document to binary document, export document manager * chore: copyWith fix * fix: json content type * fix: put default alg in top-level headers if all signatures use the same alg * chore: refactor reference uuid to give it a more meaningful name * chore: get rid of equatable from SignedDocument interface * fix: do not put alg in top-level protected headers for COSE_SIGN * chore: update field name
1 parent d01dff1 commit 14c01e4

File tree

16 files changed

+647
-173
lines changed

16 files changed

+647
-173
lines changed

catalyst_voices/packages/internal/catalyst_voices_shared/lib/src/catalyst_voices_shared.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export 'crypto/crypto_service.dart';
77
export 'crypto/key_derivation.dart';
88
export 'crypto/local_crypto_service.dart';
99
export 'dependency/dependency_provider.dart';
10+
export 'document/document_manager.dart';
1011
export 'document/extension/document_list_sort_ext.dart';
1112
export 'document/extension/document_map_to_list_ext.dart';
1213
export 'document/identifiable.dart';
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import 'dart:typed_data';
2+
3+
import 'package:catalyst_compression/catalyst_compression.dart';
4+
import 'package:catalyst_cose/catalyst_cose.dart';
5+
import 'package:catalyst_key_derivation/catalyst_key_derivation.dart';
6+
import 'package:cbor/cbor.dart';
7+
import 'package:equatable/equatable.dart';
8+
9+
part 'document_manager_impl.dart';
10+
11+
/// Parses the document from the bytes obtained from [BinaryDocument.toBytes].
12+
///
13+
/// Usually this would convert the [bytes] into a [String],
14+
/// decode a [String] into a json and then parse the data class
15+
/// from the json representation.
16+
typedef DocumentParser<T extends BinaryDocument> = T Function(Uint8List bytes);
17+
18+
/// Manages the [SignedDocument]s.
19+
abstract interface class DocumentManager {
20+
/// The default constructor for the [DocumentManager],
21+
/// provides the default implementation of the interface.
22+
const factory DocumentManager() = _DocumentManagerImpl;
23+
24+
/// Parses the document from the [bytes] representation.
25+
///
26+
/// The [parser] must be able to parse the document
27+
/// from the bytes produced by [BinaryDocument.toBytes].
28+
///
29+
/// The implementation of this method must be able to understand the [bytes]
30+
/// that are obtained from the [SignedDocument.toBytes] method.
31+
Future<SignedDocument<T>> parseDocument<T extends BinaryDocument>(
32+
Uint8List bytes, {
33+
required DocumentParser<T> parser,
34+
});
35+
36+
/// Signs the [document] with a single [privateKey].
37+
///
38+
/// The [publicKey] will be added as metadata in the signed document
39+
/// so that it's easier to identify who signed it.
40+
Future<SignedDocument<T>> signDocument<T extends BinaryDocument>(
41+
T document, {
42+
required Uint8List publicKey,
43+
required Uint8List privateKey,
44+
});
45+
}
46+
47+
/// Represents an abstract document that is protected
48+
/// with cryptographic signature.
49+
///
50+
/// The [document] payload can be UTF-8 encoded bytes, a binary data
51+
/// or anything else that can be represented in binary format.
52+
abstract interface class SignedDocument<T extends BinaryDocument> {
53+
/// The default constructor for the [SignedDocument].
54+
const SignedDocument();
55+
56+
/// A getter that returns a parsed document.
57+
T get document;
58+
59+
/// Verifies if the [document] has been signed by a private key
60+
/// that belongs to the given [publicKey].
61+
Future<bool> verifySignature(Uint8List publicKey);
62+
63+
/// Converts the document into binary representation.
64+
Uint8List toBytes();
65+
}
66+
67+
/// Represents an abstract document that can be represented in binary format.
68+
// ignore: one_member_abstracts
69+
abstract interface class BinaryDocument {
70+
/// Converts the document into a binary representation.
71+
///
72+
/// See [DocumentParser].
73+
Uint8List toBytes();
74+
75+
/// Returns the document content type.
76+
DocumentContentType get contentType;
77+
}
78+
79+
/// Defines the content type of the [BinaryDocument].
80+
enum DocumentContentType {
81+
/// The document's content type is JSON.
82+
json,
83+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
part of 'document_manager.dart';
2+
3+
const _brotliEncoding = StringValue(CoseValues.brotliContentEncoding);
4+
5+
final class _DocumentManagerImpl implements DocumentManager {
6+
const _DocumentManagerImpl();
7+
8+
@override
9+
Future<SignedDocument<T>> parseDocument<T extends BinaryDocument>(
10+
Uint8List bytes, {
11+
required DocumentParser<T> parser,
12+
}) async {
13+
final coseSign = CoseSign.fromCbor(cbor.decode(bytes));
14+
final payload = await _brotliDecompressPayload(coseSign);
15+
final document = parser(payload);
16+
return _CoseSignedDocument(coseSign, document);
17+
}
18+
19+
@override
20+
Future<SignedDocument<T>> signDocument<T extends BinaryDocument>(
21+
T document, {
22+
required Uint8List publicKey,
23+
required Uint8List privateKey,
24+
}) async {
25+
final compressedPayload = await _brotliCompressPayload(document.toBytes());
26+
27+
final coseSign = await CoseSign.sign(
28+
protectedHeaders: CoseHeaders.protected(
29+
contentEncoding: _brotliEncoding,
30+
contentType: document.contentType.asCose,
31+
),
32+
unprotectedHeaders: const CoseHeaders.unprotected(),
33+
payload: compressedPayload,
34+
signers: [_Bip32Ed25519XSigner(publicKey, privateKey)],
35+
);
36+
37+
return _CoseSignedDocument(coseSign, document);
38+
}
39+
40+
Future<Uint8List> _brotliCompressPayload(Uint8List payload) async {
41+
final compressor = CatalystCompression.instance.brotli;
42+
final compressed = await compressor.compress(payload);
43+
return Uint8List.fromList(compressed);
44+
}
45+
46+
Future<Uint8List> _brotliDecompressPayload(CoseSign coseSign) async {
47+
if (coseSign.protectedHeaders.contentEncoding == _brotliEncoding) {
48+
final compressor = CatalystCompression.instance.brotli;
49+
final decompressed = await compressor.decompress(coseSign.payload);
50+
return Uint8List.fromList(decompressed);
51+
} else {
52+
return coseSign.payload;
53+
}
54+
}
55+
}
56+
57+
final class _CoseSignedDocument<T extends BinaryDocument>
58+
extends SignedDocument<T> with EquatableMixin {
59+
final CoseSign _coseSign;
60+
61+
@override
62+
final T document;
63+
64+
const _CoseSignedDocument(this._coseSign, this.document);
65+
66+
@override
67+
Future<bool> verifySignature(Uint8List publicKey) async {
68+
return _coseSign.verify(
69+
verifier: _Bip32Ed25519XVerifier(publicKey),
70+
);
71+
}
72+
73+
@override
74+
Uint8List toBytes() {
75+
final bytes = cbor.encode(_coseSign.toCbor());
76+
return Uint8List.fromList(bytes);
77+
}
78+
79+
@override
80+
List<Object?> get props => [_coseSign, document];
81+
}
82+
83+
extension _CoseDocumentContentType on DocumentContentType {
84+
/// Maps the [DocumentContentType] into COSE representation.
85+
StringOrInt get asCose {
86+
switch (this) {
87+
case DocumentContentType.json:
88+
return const IntValue(CoseValues.jsonContentType);
89+
}
90+
}
91+
}
92+
93+
final class _Bip32Ed25519XSigner implements CatalystCoseSigner {
94+
final Uint8List publicKey;
95+
final Uint8List privateKey;
96+
97+
const _Bip32Ed25519XSigner(this.publicKey, this.privateKey);
98+
99+
@override
100+
StringOrInt? get alg => const IntValue(CoseValues.eddsaAlg);
101+
102+
@override
103+
Future<Uint8List?> get kid async => publicKey;
104+
105+
@override
106+
Future<Uint8List> sign(Uint8List data) async {
107+
final pk = Bip32Ed25519XPrivateKeyFactory.instance.fromBytes(privateKey);
108+
final signature = await pk.sign(data);
109+
return Uint8List.fromList(signature.bytes);
110+
}
111+
}
112+
113+
final class _Bip32Ed25519XVerifier implements CatalystCoseVerifier {
114+
final Uint8List publicKey;
115+
116+
const _Bip32Ed25519XVerifier(this.publicKey);
117+
118+
@override
119+
Future<Uint8List?> get kid async => publicKey;
120+
121+
@override
122+
Future<bool> verify(Uint8List data, Uint8List signature) async {
123+
final pk = Bip32Ed25519XPublicKeyFactory.instance.fromBytes(publicKey);
124+
return pk.verify(
125+
data,
126+
signature: Bip32Ed25519XSignatureFactory.instance.fromBytes(signature),
127+
);
128+
}
129+
}

catalyst_voices/packages/internal/catalyst_voices_shared/pubspec.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ environment:
99

1010
dependencies:
1111
catalyst_cardano_serialization: ^0.4.0
12+
catalyst_compression: ^0.3.0
13+
catalyst_cose: ^0.3.0
1214
catalyst_key_derivation: ^0.1.0
1315
catalyst_voices_models:
1416
path: ../catalyst_voices_models
17+
cbor: ^6.2.0
1518
collection: ^1.18.0
1619
convert: ^3.1.1
1720
cryptography: ^2.7.0

0 commit comments

Comments
 (0)