git » sdk » commit d32dd88

Encrypt outgoing attachments

author Stephen Paul Weber
2026-07-21 14:53:25 UTC
committer Stephen Paul Weber
2026-07-21 14:53:25 UTC
parent 86f24e44d6c4904e847ecfdbba4bd6ef3dd02e5c

Encrypt outgoing attachments

Use hash of the decrypted attachment

borogove/Client.hx +54 -38
borogove/Source.hx +1 -1
borogove/XEP0454.hx +24 -8
borogove/persistence/IDB.js +2 -1
borogove/persistence/Sqlite.hx +3 -1
borogove/queries/HttpUploadSlot.hx +1 -4

diff --git a/borogove/Client.hx b/borogove/Client.hx
index 8a14c8f..80f8811 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -1120,24 +1120,24 @@ class Client extends EventEmitter {
 		// We already have it
 		if (attachment.cachedAt != null) return Promise.resolve(attachment);
 
-		return fetchUris(
-			attachment.uris.copy(),
-			attachment.hashes.find(h -> h.algorithm == "sha-256") ?? attachment.hashes[0]
-		).then(id -> {
+		return fetchUris( attachment.uris.copy()).then(id -> {
 			attachment.cachedAt = id;
 			return attachment;
 		});
 	}
 
-	private function fetchUris(uris: Array<String>, hash: Null<Hash>): Promise<Null<String>> {
+	private function fetchUris(uris: Array<String>): Promise<Null<String>> {
 		if (uris.length < 1) return Promise.resolve(null);
 
 		final uri = uris.shift();
 		final aesgcm = XEP0454.parse(uri);
 		if (aesgcm != null) {
-			return XEP0454.fetch(aesgcm, hash).then(
+			return XEP0454.fetch(aesgcm).then(
 				data -> persistence.storeMedia(aesgcm.mime, data),
-				e -> fetchUris(uris, hash)
+				e -> {
+					trace("fetchAttachment", e);
+					return fetchUris(uris);
+				}
 			);
 		}
 
@@ -1159,59 +1159,75 @@ class Client extends EventEmitter {
 				});
 			}).then(
 				r -> persistence.storeMedia(r.mime, r.body),
-				e -> fetchUris(uris, hash)
+				e -> fetchUris(uris)
 			);
 		}
 
-		return fetchUris(uris, hash);
+		return fetchUris(uris);
 	}
 
 	/**
 		Turn a file into a ChatAttachment for attaching to a ChatMessage
 
 		@param source The AttachmentSource to use
-		@returns Promise resolving to a ChatAttachment or null
+		@returns Promise resolving to a ChatAttachment
 	**/
-	public function prepareAttachment(source: AttachmentSource): Promise<Null<ChatAttachment>> {
+	public function prepareAttachment(source: AttachmentSource): Promise<ChatAttachment> {
 		return persistence.findServicesWithFeature(accountId(), "urn:xmpp:http:upload:0").then((services) -> {
-			final sha256 = new Sha256();
-			return new Promise((resolve, reject) -> {
-				source.tinkSource().chunked().forEach((chunk) -> {
-					sha256.update(chunk);
-					return tink.streams.Stream.Handled.Resume;
-				}).handle((o) -> switch o {
-					case Depleted:
-						prepareAttachmentFor(source, services, [new Hash("sha-256", sha256.digest().getData())], resolve);
-					default:
-						trace("Error computing attachment hash", o);
-						reject(o);
-				});
+			final sha256 = Hash.sha256incr();
+			final httpPut = (tsource: tink.io.Source.RealSource, size: Int) -> {
+				return prepareAttachmentFor(
+					tsource,
+					source.name,
+					size,
+					source.type,
+					services
+				);
+			};
+
+			final tinkSource = source.tinkSource().chunked().map((chunk) -> {
+				sha256.update((chunk : Bytes).getData());
+				return chunk;
 			});
+
+			// Giant attachments go unencrypted for now...
+			return thenshim.PromiseTools.all(([
+				if (source.size < 1000000000) {
+					XEP0454.put(tinkSource, httpPut).then(o -> new ChatAttachment(source.name, source.type, o.size, [o.uri], [sha256.digest()]));
+				} else {
+					httpPut(tinkSource, source.size).then(uri -> new ChatAttachment(source.name, source.type, source.size, [uri], [sha256.digest()]));
+				},
+				persistence.storeMedia(source.type, Source.ofTinkSource(tinkSource))
+			] : Array<Dynamic>)).then(results -> results[0]);
 		});
 	}
 
-	private function prepareAttachmentFor(source: AttachmentSource, services: Array<{ serviceId: String }>, hashes: Array<Hash>, callback: (Null<ChatAttachment>)->Void) {
+	private function prepareAttachmentFor(source: tink.io.Source.RealSource, name: String, size: Int, mime: String, services: Array<{ serviceId: String }>): Promise<String> {
 		if (services.length < 1) {
 			trace("No HTTP upload service found");
-			callback(null);
-			return;
+			return Promise.reject("failed");
 		}
-		final httpUploadSlot = new HttpUploadSlot(services[0].serviceId, source.name, source.size, source.type, hashes);
-		httpUploadSlot.onFinished(() -> {
-			final slot = httpUploadSlot.getResult();
-			if (slot == null) {
-				prepareAttachmentFor(source, services.slice(1), hashes, callback);
-			} else {
-				tink.http.Client.fetch(slot.put, { method: PUT, headers: slot.putHeaders.concat([new tink.http.Header.HeaderField("Content-Length", source.size)]), body: tink.io.Source.RealSourceTools.idealize(source.tinkSource(), (e) -> { trace("prepareAttachmentFor ERROR", e); throw e; }) }).all()
-					.handle((o) -> switch o {
+		final httpUploadSlot = new HttpUploadSlot(services[0].serviceId, name, size, mime);
+		return new Promise((resolve, reject) -> {
+			httpUploadSlot.onFinished(() -> {
+				final slot = httpUploadSlot.getResult();
+				if (slot == null) {
+					prepareAttachmentFor(source, name, size, mime, services.slice(1)).then(resolve, reject);
+				} else {
+					tink.http.Client.fetch( slot.put, {
+						method: PUT,
+						headers: slot.putHeaders.concat([new tink.http.Header.HeaderField("Content-Length", size)]),
+						body: tink.io.Source.RealSourceTools.idealize(source, (e) -> { reject(e); throw e; })
+					}).all().handle((o) -> switch o {
 						case Success(res) if (res.header.statusCode == 201):
-							callback(new ChatAttachment(source.name, source.type, source.size, [slot.get], hashes));
+							resolve(slot.get);
 						default:
-							prepareAttachmentFor(source, services.slice(1), hashes, callback);
+							prepareAttachmentFor(source, name, size, mime, services.slice(1)).then(resolve, reject);
 					});
-			}
+				}
+			});
+			sendQuery(httpUploadSlot);
 		});
-		sendQuery(httpUploadSlot);
 	}
 
 	/**
diff --git a/borogove/Source.hx b/borogove/Source.hx
index 53fc8a6..dbc87c9 100644
--- a/borogove/Source.hx
+++ b/borogove/Source.hx
@@ -29,7 +29,7 @@ abstract Source(UnderlyingSource) {
 		#end
 	}
 
-	@:from static function ofTinkSource(source: RealSource) {
+	@:from public static function ofTinkSource(source: RealSource) {
 		#if js
 		var stream: ReadableStream = null;
 		stream = new ReadableStream({
diff --git a/borogove/XEP0454.hx b/borogove/XEP0454.hx
index 6585da4..4f8fb18 100644
--- a/borogove/XEP0454.hx
+++ b/borogove/XEP0454.hx
@@ -5,7 +5,7 @@ import thenshim.Promise;
 
 using StringTools;
 
-function fetch(aesgcm: AesGcm, hash: Null<Hash> = null): Promise<Bytes> {
+function fetch(aesgcm: AesGcm): Promise<Bytes> {
 	// Fetch all data into memory because AES-GCM APIs really want to verify
 	// everything before giving access to plaintext.
 	// TODO: This means we ought to refuse to download if the size is too big:
@@ -18,13 +18,6 @@ function fetch(aesgcm: AesGcm, hash: Null<Hash> = null): Promise<Bytes> {
 				reject(e);
 		});
 	}).then((encrypted) -> {
-		if (hash != null) {
-			final compare = Hash.mk(hash.algorithm, encrypted);
-			if (compare != null && !hash.equals(compare)) {
-				throw "Hash mismatch: " + hash + " != " + compare;
-			}
-		}
-
 		#if js
 		final subtle: js.html.SubtleCrypto = untyped globalThis.crypto.subtle;
 		return (subtle.importKey("raw", aesgcm.key.getData(), "AES-GCM", false, ["decrypt"]).then(key ->
@@ -37,6 +30,29 @@ function fetch(aesgcm: AesGcm, hash: Null<Hash> = null): Promise<Bytes> {
 	});
 }
 
+function put(source: tink.io.Source.RealSource, httpPut: (tink.io.Source.RealSource, Int)->Promise<String>): Promise<{ uri: String, size: Int }> {
+	final iv = haxe.crypto.random.SecureRandom.bytes(12);
+	final key = haxe.crypto.random.SecureRandom.bytes(32);
+
+	return new Promise((resolve, reject) -> {
+		tink.io.Source.RealSourceTools.all(source).handle(o -> switch o {
+			case Success(bytes): resolve((bytes : Bytes));
+			case Failure(e): reject(e);
+		});
+	}).then(bytes -> {
+		#if js
+		final subtle: js.html.SubtleCrypto = untyped globalThis.crypto.subtle;
+		final encryptedP: Promise<Bytes> = subtle.importKey("raw", key.getData(), "AES-GCM", false, ["encrypt"]).then(key ->
+			subtle.encrypt({ name: "AES-GCM", iv: iv.getData() }, key, bytes.getData())
+		).then(encrypted -> Bytes.ofData(encrypted));
+		#else
+		final aes = new haxe.crypto.Aes(key, iv);
+		final encryptedP = Promise.resolve(aes.encrypt(haxe.crypto.mode.Mode.GCM, bytes, Bytes.alloc(0), 16));
+		#end
+		return encryptedP.then(encrypted -> httpPut(encrypted, encrypted.length).then(uri -> ({ uri: ~/^https?:\/\//.map(uri, _ -> "aesgcm://") + "#" + iv.toHex() + key.toHex(), size: encrypted.length })));
+	});
+}
+
 typedef AesGcm = {
 	https: String,
 	iv: haxe.io.Bytes,
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index 44c2f1f..cef1d01 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -337,7 +337,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 		message.to = value.to ? borogove_JID.parse(value.to) : message.recipients[0];
 		message.replyTo = value.replyTo.map((r) => borogove_JID.parse(r));
 		message.threadId = value.threadId;
-		message.attachments = (value.attachments ?? []).map(a => new borogove_ChatAttachment(a.name, a.mime, a.size, a.uris, a.hashes));
+		message.attachments = (value.attachments ?? []).map(a => new borogove_ChatAttachment(a.name, a.mime, a.size, a.uris, (a.hashes ?? []).map(h => new borogove_Hash(h.algorithm, h.hash))));
 		message.linkMetadata = value.linkMetadata ?? [];
 		message.reactions = hydrateReactions(value.reactions, message.timestamp);
 		message.text = value.text;
@@ -878,6 +878,7 @@ tx.onerror = console.error;
 					} else {
 						store.put(toPut).onerror = console.error;
 					}
+					await Promise.all(message.attachments.map(a => a.lookup(this)));
 					return message;
 				}
 
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index f59a7e5..24d11a9 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -732,7 +732,9 @@ class Sqlite implements Persistence implements KeyValueStore {
 			).then(_ ->
 				thenshim.PromiseTools.all(messages.map(m -> fetchFromStub(accountId, m)))
 			).then(ms ->
-				hydrateReplyTo(accountId, ms, replyTos)
+				thenshim.PromiseTools.all(ms.flatMap(m -> m.attachments.map(a -> a.lookup(this)))).then(_ ->
+					hydrateReplyTo(accountId, ms, replyTos)
+				)
 			).then(ms ->
 				hydrateReactions(accountId, ms)
 			)
diff --git a/borogove/queries/HttpUploadSlot.hx b/borogove/queries/HttpUploadSlot.hx
index df0b574..fab3529 100644
--- a/borogove/queries/HttpUploadSlot.hx
+++ b/borogove/queries/HttpUploadSlot.hx
@@ -19,16 +19,13 @@ class HttpUploadSlot extends GenericQuery {
 	public var responseStanza(default, null):Stanza;
 	private var result: { put: String, putHeaders: Array<tink.http.Header.HeaderField>, get: String };
 
-	public function new(to: String, filename: String, size: Int, mime: String, hashes: Array<Hash>) {
+	public function new(to: String, filename: String, size: Int, mime: String) {
 		/* Build basic query */
 		queryId = ID.unique();
 		queryStanza = new Stanza(
 			"iq",
 			{ to: to, type: "get", id: queryId }
 		).tag("request", { xmlns: xmlns, filename: filename, size: Std.string(size), "content-type": mime });
-		for (hash in hashes) {
-			queryStanza.textTag("hash", Base64.encode(Bytes.ofData(hash.hash)), { xmlns: "urn:xmpp:hashes:2", algo: hash.algorithm });
-		}
 		queryStanza.up();
 	}