Skip to content
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

Closed
wants to merge 13 commits into from
77 changes: 77 additions & 0 deletions lib/src/content/encoded_content_ext.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'dart:io';

import 'package:xmtp/src/content/attachment_codec.dart';
import 'package:xmtp/src/content/remote_attachment_codec.dart';

import '../../xmtp.dart';

extension EncodedDecompressExt on EncodedContent {
dynamic decoded() {
var encodedContent = this;
if (hasCompression()) {
encodedContent = decompressContent();
}
Comment on lines +11 to +13
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be the responsibility of CodecRegistry to apply any supported compression schemes whenever the something needs to .decode compressed content (and not just for remote attachments).

var codecRegistry = CodecRegistry();
codecRegistry.registerCodec(AttachmentCodec());
codecRegistry.registerCodec(RemoteAttachmentCodec());
return codecRegistry.decode(encodedContent);
Comment on lines +14 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this breaks the codec architecture in flutter (which uses per-instance codecs).
see note below re accepting a CodecRegistry as part of RemoteAttachment.load.

}

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this works (it looks like it's casting List<int> as EncodedContent)

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This responsibility probably belongs in CodecRegistry -- which already orchestrates the other mechanics of encoding/decoding.

To put it into CodecRegistry you could do something like this:

+ 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.

}
165 changes: 165 additions & 0 deletions lib/src/content/remote_attachment_codec.dart
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably live on the Client as a .download method which can perform this download and already knows how to .decode it:

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 .decrypt to the RemoteAttachment which is basically the same as your .decryptEncoded but instead of static it is an instance method and accepts the List<int> encryptedPayload as a parameter instead. This lets you get rid of the intermediate class EncryptedEncodedContent (which is basically just RemoteAttachment + List<int> payload).


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());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.forTesting() 👀

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.";
}
}
51 changes: 51 additions & 0 deletions test/content/remote_attachments_codec_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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';

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);
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"));
var encryptedEncodedContent =
await RemoteAttachment.encodedEncrypted(attachment, AttachmentCodec());
final file = File("abcdefg");
file.writeAsBytesSync(encryptedEncodedContent.payload);
expect(
() => RemoteAttachment.from(
Uri.parse("http://abcdefg"), encryptedEncodedContent),
throwsStateError);
});
}
Loading