UNPKG

@xmtp/content-type-remote-attachment

Version:

An XMTP content type to support sending file attachments that are stored off network

188 lines (163 loc) 5.06 kB
import * as secp from "@noble/secp256k1"; import { ContentTypeId, type CodecRegistry, type ContentCodec, type EncodedContent, } from "@xmtp/content-type-primitives"; import { content as proto } from "@xmtp/proto"; import { Ciphertext, crypto, decrypt, encrypt } from "./encryption"; export const ContentTypeRemoteAttachment = new ContentTypeId({ authorityId: "xmtp.org", typeId: "remoteStaticAttachment", versionMajor: 1, versionMinor: 0, }); export type EncryptedEncodedContent = { digest: string; salt: Uint8Array; nonce: Uint8Array; secret: Uint8Array; payload: Uint8Array; }; export type RemoteAttachment = { url: string; contentDigest: string; salt: Uint8Array; nonce: Uint8Array; secret: Uint8Array; scheme: string; contentLength: number; filename: string; }; export type RemoteAttachmentParameters = { contentDigest: string; salt: string; nonce: string; secret: string; scheme: string; contentLength: string; filename: string; }; export class RemoteAttachmentCodec implements ContentCodec<RemoteAttachment, RemoteAttachmentParameters> { static async load<T = unknown>( remoteAttachment: RemoteAttachment, codecRegistry: CodecRegistry, ): Promise<T> { const response = await fetch(remoteAttachment.url); if (!response.ok) { throw new Error( `unable to fetch remote attachment at ${remoteAttachment.url}: ${response.status} ${response.statusText}`, ); } const payload = new Uint8Array(await response.arrayBuffer()); if (payload.length === 0) { throw new Error( `no payload for remote attachment at ${remoteAttachment.url}`, ); } const digestBytes = new Uint8Array( await crypto.subtle.digest("SHA-256", payload), ); const digest = secp.etc.bytesToHex(digestBytes); if (digest !== remoteAttachment.contentDigest) { throw new Error("content digest does not match"); } const ciphertext = new Ciphertext({ aes256GcmHkdfSha256: { hkdfSalt: remoteAttachment.salt, gcmNonce: remoteAttachment.nonce, payload, }, }); const encodedContentData = await decrypt( ciphertext, remoteAttachment.secret, ); const encodedContent = proto.EncodedContent.decode(encodedContentData); if (!encodedContent.type) { throw new Error("no content type"); } const codec = codecRegistry.codecFor( new ContentTypeId(encodedContent.type), ); if (!codec) { throw new Error(`no codec found for ${encodedContent.type.typeId}`); } return codec.decode(encodedContent as EncodedContent, codecRegistry) as T; } static async encodeEncrypted<T>( content: T, codec: ContentCodec<T>, ): Promise<EncryptedEncodedContent> { const secret = crypto.getRandomValues(new Uint8Array(32)); const encodedContent = proto.EncodedContent.encode( codec.encode(content, { codecFor() { return undefined; }, }), ).finish(); const ciphertext = await encrypt(encodedContent, secret); const salt = ciphertext.aes256GcmHkdfSha256?.hkdfSalt; const nonce = ciphertext.aes256GcmHkdfSha256?.gcmNonce; const payload = ciphertext.aes256GcmHkdfSha256?.payload; if (!salt || !nonce || !payload) { throw new Error("missing encryption key"); } const digestBytes = new Uint8Array( await crypto.subtle.digest("SHA-256", payload), ); const digest = secp.etc.bytesToHex(digestBytes); return { digest, secret, salt, nonce, payload, }; } get contentType(): ContentTypeId { return ContentTypeRemoteAttachment; } encode(content: RemoteAttachment) { if (!content.url.startsWith("https")) { throw new Error("scheme must be https"); } return { type: ContentTypeRemoteAttachment, parameters: { contentDigest: content.contentDigest, salt: secp.etc.bytesToHex(content.salt), nonce: secp.etc.bytesToHex(content.nonce), secret: secp.etc.bytesToHex(content.secret), scheme: content.scheme, contentLength: String(content.contentLength), filename: content.filename, }, content: new TextEncoder().encode(content.url), }; } decode( content: EncodedContent<RemoteAttachmentParameters>, ): RemoteAttachment { return { url: new TextDecoder().decode(content.content), contentDigest: content.parameters.contentDigest, salt: secp.etc.hexToBytes(content.parameters.salt), nonce: secp.etc.hexToBytes(content.parameters.nonce), secret: secp.etc.hexToBytes(content.parameters.secret), scheme: content.parameters.scheme, contentLength: parseInt(content.parameters.contentLength, 10), filename: content.parameters.filename, }; } fallback(content: RemoteAttachment): string | undefined { return `Can’t display "${content.filename}". This app doesn’t support attachments.`; } shouldPush() { return true; } }