-
Notifications
You must be signed in to change notification settings - Fork 6
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
Remote Attachment Codec #105
Changes from 12 commits
9a813db
cc289cd
9f43738
6eed5dd
4d33b9d
39836ad
07e22c2
57cf8cb
df954f3
9450b4c
d4ee263
60d572a
bebfd28
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
import 'dart:io'; | ||
import 'package:xmtp/xmtp.dart'; | ||
giovasdistillery marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
extension EncodedDecompressExt on EncodedContent { | ||
dynamic decoded() { | ||
var encodedContent = this; | ||
if (hasCompression()) { | ||
encodedContent = decompressContent(); | ||
} | ||
Comment on lines
+11
to
+13
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this should be the responsibility of |
||
return Client.codecs.decode(encodedContent); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This doesn't belong here. Instead it should live inside of Future<DecodedContent> decode(xmtp.EncodedContent encoded) async {
var codec = _codecFor(encoded.type);
if (codec == null) {
if (encoded.hasFallback()) {
return DecodedContent(contentTypeText, encoded.fallback);
}
throw StateError(
"unable to decode unsupported type ${_key(encoded.type)}");
}
- return DecodedContent(encoded.type, await codec.decode(encoded));
+ var decompressed = _decompress(encoded);
+ return DecodedContent(decompressed.type, await codec.decode(decompressed));
} |
||
} | ||
|
||
EncodedContent compressContent() { | ||
var copy = this; | ||
switch (compression) { | ||
case Compression.COMPRESSION_DEFLATE: | ||
copy.compression = Compression.COMPRESSION_DEFLATE; | ||
copy.content = EncodedContentCompression.DEFLATE.compress(content); | ||
break; | ||
case Compression.COMPRESSION_GZIP: | ||
copy.compression = Compression.COMPRESSION_GZIP; | ||
copy.content = EncodedContentCompression.GZIP.compress(content); | ||
break; | ||
} | ||
return copy; | ||
} | ||
|
||
EncodedContent decompressContent() { | ||
if (!hasCompression()) { | ||
return this; | ||
} | ||
var copy = this; | ||
switch (compression) { | ||
case Compression.COMPRESSION_DEFLATE: | ||
copy = EncodedContentCompression.DEFLATE.decompress(content) | ||
as EncodedContent; | ||
break; | ||
case Compression.COMPRESSION_GZIP: | ||
copy = EncodedContentCompression.GZIP.decompress(content) | ||
as EncodedContent; | ||
Comment on lines
+42
to
+47
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this works (it looks like it's casting |
||
break; | ||
} | ||
return copy; | ||
} | ||
} | ||
|
||
enum EncodedContentCompression { | ||
DEFLATE, | ||
GZIP; | ||
} | ||
|
||
extension EncodedContentCompressionExt on EncodedContentCompression { | ||
List<int> compress(List<int> content) { | ||
switch (this) { | ||
case EncodedContentCompression.DEFLATE: | ||
return zlib.encode(content); | ||
case EncodedContentCompression.GZIP: | ||
return gzip.encode(content); | ||
} | ||
} | ||
|
||
List<int> decompress(List<int> content) { | ||
switch (this) { | ||
case EncodedContentCompression.DEFLATE: | ||
return zlib.decode(content); | ||
case EncodedContentCompression.GZIP: | ||
return gzip.decode(content); | ||
} | ||
} | ||
Comment on lines
+59
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This responsibility probably belongs in To put it into + import 'dart:convert' as convert;
+ import 'dart:io' as io;
+ typedef Compressor = convert.Codec<List<int>, List<int>>;
class CodecRegistry implements Codec<DecodedContent> {
final Map<String, Codec> _codecs = {};
+ final Map<xmtp.Compression, Compressor> _compressors = {
+ xmtp.Compression.COMPRESSION_GZIP: io.gzip,
+ xmtp.Compression.COMPRESSION_DEFLATE: io.zlib,
+ }; // TODO: consider supporting custom compressors
...
Future<DecodedContent> decode(xmtp.EncodedContent encoded) async {
+ if (encoded.hasCompression()) {
+ var compressor = _compressors[encoded.compression];
+ if (compressor == null) {
+ throw StateError(
+ "unable to decode unsupported compression ${encoded.compression}");
+ }
+ var decompressed = compressor.decode(encoded.content);
+ encoded = xmtp.EncodedContent()
+ ..mergeFromMessage(encoded)
+ ..clearCompression()
+ ..content = decompressed;
+ }
var codec = _codecFor(encoded.type);
... and then the registry can handle compression wherever it shows up. |
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,165 @@ | ||
import 'dart:convert'; | ||
|
||
import 'package:cryptography/cryptography.dart'; | ||
import 'package:web3dart/crypto.dart'; | ||
import 'package:xmtp/src/common/crypto.dart'; | ||
import 'package:xmtp/src/content/attachment_codec.dart'; | ||
import 'package:xmtp/src/content/encoded_content_ext.dart'; | ||
|
||
import '../../xmtp.dart'; | ||
import 'package:xmtp_proto/xmtp_proto.dart' as xmtp; | ||
import 'dart:typed_data'; | ||
import 'package:http/http.dart' as http; | ||
|
||
class EncryptedEncodedContent { | ||
final String contentDigest; | ||
final Uint8List secret; | ||
final Uint8List salt; | ||
final Uint8List nonce; | ||
final Uint8List payload; | ||
final int? contentLength; | ||
final String? fileName; | ||
|
||
EncryptedEncodedContent(this.contentDigest, this.secret, this.salt, | ||
this.nonce, this.payload, this.contentLength, this.fileName); | ||
} | ||
|
||
class RemoteAttachment { | ||
final Uri url; | ||
final String contentDigest; | ||
final Uint8List secret; | ||
final Uint8List salt; | ||
final Uint8List nonce; | ||
final String scheme; | ||
final int? contentLength; | ||
final String? fileName; | ||
final Fetcher fetcher = HttpFetcher(); | ||
|
||
RemoteAttachment(this.url, this.contentDigest, this.secret, this.salt, | ||
this.nonce, this.scheme, this.contentLength, this.fileName); | ||
|
||
dynamic load() async { | ||
var payload = await fetcher.fetch(url); | ||
if (payload.isEmpty) { | ||
throw StateError("No remote attachment payload"); | ||
} | ||
var encrypted = EncryptedEncodedContent( | ||
contentDigest, secret, salt, nonce, payload, contentLength, fileName); | ||
var decrypted = await decryptEncoded(encrypted); | ||
return decrypted.decoded; | ||
} | ||
Comment on lines
+41
to
+50
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should probably live on the class Client ...
...
Future<DecodedContent> download(RemoteAttachment attachment) async {
final Fetcher fetcher = HttpFetcher(); // TODO(perf): consider re-using
var payload = await fetcher.fetch(attachment.url);
if (payload.isEmpty) {
throw StateError("No remote attachment payload");
}
var decrypted = await attachment.decrypt(payload);
return decode(decrypted);
} Note: I added |
||
|
||
static Future<EncodedContent> decryptEncoded( | ||
EncryptedEncodedContent encrypted) async { | ||
var hashPayload = sha256(encrypted.payload); | ||
if (bytesToHex(hashPayload) != encrypted.contentDigest) { | ||
throw StateError("content digest does not match"); | ||
} | ||
|
||
var aes = Ciphertext_Aes256gcmHkdfsha256( | ||
hkdfSalt: encrypted.salt, | ||
gcmNonce: encrypted.nonce, | ||
payload: encrypted.payload); | ||
|
||
var cipherText = xmtp.Ciphertext(aes256GcmHkdfSha256: aes); | ||
var decrypted = await decrypt(encrypted.secret, cipherText); | ||
|
||
return EncodedContent.fromBuffer(decrypted); | ||
} | ||
|
||
static Future<EncryptedEncodedContent> encodedEncrypted( | ||
dynamic content, Codec<dynamic> codec) async { | ||
var secret = List<int>.generate( | ||
32, (index) => SecureRandom.forTesting().nextUint32()); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
var encodedContent = await codec.encode(content); | ||
var cipherText = await encrypt(secret, encodedContent.writeToBuffer()); | ||
var contentDigest = | ||
bytesToHex(sha256(cipherText.aes256GcmHkdfSha256.payload)); | ||
var fileName = content is Attachment ? content.filename : null; | ||
return EncryptedEncodedContent( | ||
contentDigest, | ||
Uint8List.fromList(secret), | ||
Uint8List.fromList(cipherText.aes256GcmHkdfSha256.hkdfSalt), | ||
Uint8List.fromList(cipherText.aes256GcmHkdfSha256.gcmNonce), | ||
Uint8List.fromList(cipherText.aes256GcmHkdfSha256.payload), | ||
encodedContent.content.length, | ||
fileName); | ||
} | ||
|
||
static RemoteAttachment from( | ||
Uri url, EncryptedEncodedContent encryptedEncodedContent) { | ||
if (url.scheme != "https") { | ||
throw StateError("scheme must be https://"); | ||
} | ||
|
||
return RemoteAttachment( | ||
url, | ||
encryptedEncodedContent.contentDigest, | ||
encryptedEncodedContent.secret, | ||
encryptedEncodedContent.salt, | ||
encryptedEncodedContent.nonce, | ||
url.scheme, | ||
encryptedEncodedContent.contentLength, | ||
encryptedEncodedContent.fileName); | ||
} | ||
} | ||
|
||
abstract class Fetcher { | ||
Future<Uint8List> fetch(Uri url); | ||
} | ||
|
||
class HttpFetcher implements Fetcher { | ||
@override | ||
Future<Uint8List> fetch(Uri url) async { | ||
return await http.readBytes(url); | ||
} | ||
} | ||
|
||
final contentTypeRemoteAttachments = xmtp.ContentTypeId( | ||
authorityId: "xmtp.org", | ||
typeId: "remoteStaticAttachment", | ||
versionMajor: 1, | ||
versionMinor: 0, | ||
); | ||
|
||
class RemoteAttachmentCodec extends Codec<RemoteAttachment> { | ||
@override | ||
xmtp.ContentTypeId get contentType => contentTypeRemoteAttachments; | ||
|
||
@override | ||
Future<RemoteAttachment> decode(EncodedContent encoded) async => | ||
RemoteAttachment( | ||
Uri.parse(utf8.decode(encoded.content)), | ||
encoded.parameters["contentDigest"] ?? "", | ||
Uint8List.fromList((encoded.parameters["secret"] ?? "").codeUnits), | ||
Uint8List.fromList((encoded.parameters["salt"] ?? "").codeUnits), | ||
Uint8List.fromList((encoded.parameters["nonce"] ?? "").codeUnits), | ||
encoded.parameters["scheme"] ?? "", | ||
encoded.content.length, | ||
encoded.parameters["filename"] ?? "", | ||
); | ||
|
||
@override | ||
Future<xmtp.EncodedContent> encode(RemoteAttachment decoded) async { | ||
var content = Uint8List.fromList(decoded.url.toString().codeUnits); | ||
var parameters = { | ||
"contentDigest": decoded.contentDigest, | ||
"secret": bytesToHex(decoded.secret), | ||
"salt": bytesToHex(decoded.salt), | ||
"nonce": bytesToHex(decoded.nonce), | ||
"scheme": decoded.scheme, | ||
"contentLength": content.length.toString(), | ||
"filename": decoded.fileName ?? "", | ||
}; | ||
return EncodedContent( | ||
type: contentTypeRemoteAttachments, | ||
content: content, | ||
parameters: parameters, | ||
); | ||
} | ||
|
||
@override | ||
String? fallback(RemoteAttachment content) { | ||
return "Can’t display \"${content.fileName}\". This app doesn’t support remote attachments."; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import 'dart:convert'; | ||
import 'dart:io'; | ||
|
||
import 'package:flutter_test/flutter_test.dart'; | ||
import 'package:xmtp/src/content/attachment_codec.dart'; | ||
import 'package:xmtp/src/content/encoded_content_ext.dart'; | ||
import 'package:xmtp/src/content/remote_attachment_codec.dart'; | ||
import 'package:xmtp/xmtp.dart'; | ||
|
||
void main() { | ||
test('Remote attachment must be encoded and decoded', () async { | ||
var attachment = | ||
Attachment("test.txt", "text/plain", utf8.encode("Hello world")); | ||
var codec = RemoteAttachmentCodec(); | ||
var url = Uri.parse("https://abcdefg"); | ||
var encryptedEncodedContent = | ||
RemoteAttachment.encodedEncrypted(attachment, AttachmentCodec()); | ||
var remoteAttachment = | ||
RemoteAttachment.from(url, await encryptedEncodedContent); | ||
var encoded = await codec.encode(remoteAttachment); | ||
expect(encoded.type, contentTypeRemoteAttachments); | ||
expect(encoded.content.isNotEmpty, true); | ||
RemoteAttachment decoded = await codec.decode(encoded); | ||
expect(decoded.url, url); | ||
expect(decoded.fileName, 'test.txt'); | ||
}); | ||
|
||
test('Encryption content should be decryptable', () async { | ||
var attachment = | ||
Attachment("test.txt", "text/plain", utf8.encode("Hello world")); | ||
var encrypted = | ||
await RemoteAttachment.encodedEncrypted(attachment, AttachmentCodec()); | ||
var decrypted = await RemoteAttachment.decryptEncoded(encrypted); | ||
Client.registerCodecs([RemoteAttachmentCodec(), AttachmentCodec()]); | ||
var decoded = await decrypted.decoded(); | ||
expect(attachment.filename, (decoded.content as Attachment).filename); | ||
expect(attachment.mimeType, (decoded.content as Attachment).mimeType); | ||
expect(attachment.data, (decoded.content as Attachment).data); | ||
}); | ||
|
||
test('Cannot use non https url', () async { | ||
var attachment = | ||
Attachment("test.txt", "text/plain", utf8.encode("Hello world")); | ||
Client.registerCodecs([RemoteAttachmentCodec(), AttachmentCodec()]); | ||
var encryptedEncodedContent = | ||
await RemoteAttachment.encodedEncrypted(attachment, AttachmentCodec()); | ||
final file = File("abcdefg"); | ||
file.writeAsBytesSync(encryptedEncodedContent.payload); | ||
expect( | ||
() => RemoteAttachment.from( | ||
Uri.parse("http://abcdefg"), encryptedEncodedContent), | ||
throwsStateError); | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we want this.
I'm assuming this is because you wanted access to the codecs from elsewhere but we don't keep a static registry.
Instead, there is already a
_codecs
registry as part of theClient
instance.(notes on how to use it within your codec below).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The main problem with that change is that if Client doesn't contain the proper codecs registered at the moment of trying to decode then it will fail, the only way to create a Client object is from a wallet or conversation, in Android and iOS is completely different, is possible to register codecs statically, I was trying to do the same as Android and iOS do. I will create a codec registry in that method, otherwise if I try to use the actual
_codecs
variable inClient
is not possible to access from that point of the code and I will need to move it to a different level and again is going to be different from the other mobile implementations.Please check
https://github.com/xmtp/xmtp-android/blob/main/library/src/main/java/org/xmtp/android/library/codecs/ContentCodec.kt
https://github.com/xmtp/xmtp-android/pull/45/files
in Everywhere there is a way to register a codec using
Client.register
(static way) or getting the codecs of theClient
object.if still my last changes are not good for you, please let me know and have a meeting to discuss the proper approach in order to make the change as you want :)