UNPKG

@xmtp/content-type-remote-attachment

Version:

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

222 lines (216 loc) 8.76 kB
import { ContentTypeId } from '@xmtp/content-type-primitives'; import * as secp from '@noble/secp256k1'; import { ciphertext, content } from '@xmtp/proto'; import { webcrypto } from 'node:crypto'; const ContentTypeAttachment = new ContentTypeId({ authorityId: "xmtp.org", typeId: "attachment", versionMajor: 1, versionMinor: 0, }); class AttachmentCodec { get contentType() { return ContentTypeAttachment; } encode(content) { return { type: ContentTypeAttachment, parameters: { filename: content.filename, mimeType: content.mimeType, }, content: content.data, }; } decode(content) { return { filename: content.parameters.filename, mimeType: content.parameters.mimeType, data: content.content, }; } fallback(content) { return `Can’t display "${content.filename}". This app doesn’t support attachments.`; } shouldPush() { return true; } } const KDFSaltSize = 32; // bytes // AES-GCM defaults from https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams const AESGCMNonceSize = 12; // property iv const AESGCMTagLength = 16; // property tagLength // Ciphertext packages the encrypted ciphertext with the salt and nonce used to produce it. // salt and nonce are not secret, and should be transmitted/stored along with the encrypted ciphertext. class Ciphertext { aes256GcmHkdfSha256; constructor(obj) { if (!obj.aes256GcmHkdfSha256) { throw new Error("invalid ciphertext"); } if (obj.aes256GcmHkdfSha256.payload.length < AESGCMTagLength) { throw new Error(`invalid ciphertext ciphertext length: ${obj.aes256GcmHkdfSha256.payload.length}`); } if (obj.aes256GcmHkdfSha256.hkdfSalt.length !== KDFSaltSize) { throw new Error(`invalid ciphertext salt length: ${obj.aes256GcmHkdfSha256.hkdfSalt.length}`); } if (obj.aes256GcmHkdfSha256.gcmNonce.length !== AESGCMNonceSize) { throw new Error(`invalid ciphertext nonce length: ${obj.aes256GcmHkdfSha256.gcmNonce.length}`); } this.aes256GcmHkdfSha256 = obj.aes256GcmHkdfSha256; } toBytes() { return ciphertext.Ciphertext.encode(this).finish(); } static fromBytes(bytes) { return new Ciphertext(ciphertext.Ciphertext.decode(bytes)); } } const hkdfNoInfo = new Uint8Array().buffer; new Uint8Array().buffer; // symmetric authenticated encryption of plaintext using the secret; // additionalData is used to protect un-encrypted parts of the message (header) // in the authentication scope of the encryption. async function encrypt(plain, secret, additionalData) { const salt = webcrypto.getRandomValues(new Uint8Array(KDFSaltSize)); const nonce = webcrypto.getRandomValues(new Uint8Array(AESGCMNonceSize)); const key = await hkdf(secret, salt); const encrypted = await webcrypto.subtle.encrypt(aesGcmParams(nonce), key, plain); return new Ciphertext({ aes256GcmHkdfSha256: { payload: new Uint8Array(encrypted), hkdfSalt: salt, gcmNonce: nonce, }, }); } // symmetric authenticated decryption of the encrypted ciphertext using the secret and additionalData async function decrypt(encrypted, secret, additionalData) { if (!encrypted.aes256GcmHkdfSha256) { throw new Error("invalid payload ciphertext"); } const key = await hkdf(secret, encrypted.aes256GcmHkdfSha256.hkdfSalt); const decrypted = await webcrypto.subtle.decrypt(aesGcmParams(encrypted.aes256GcmHkdfSha256.gcmNonce), key, encrypted.aes256GcmHkdfSha256.payload); return new Uint8Array(decrypted); } // helper for building Web Crypto API encryption parameter structure function aesGcmParams(nonce, additionalData) { const spec = { name: "AES-GCM", iv: nonce, }; return spec; } // Derive AES-256-GCM key from a shared secret and salt. // Returns CryptoKey suitable for the encrypt/decrypt API async function hkdf(secret, salt) { const key = await webcrypto.subtle.importKey("raw", secret, "HKDF", false, [ "deriveKey", ]); return webcrypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt, info: hkdfNoInfo }, key, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); } const ContentTypeRemoteAttachment = new ContentTypeId({ authorityId: "xmtp.org", typeId: "remoteStaticAttachment", versionMajor: 1, versionMinor: 0, }); class RemoteAttachmentCodec { static async load(remoteAttachment, codecRegistry) { 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 webcrypto.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 = content.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, codecRegistry); } static async encodeEncrypted(content$1, codec) { const secret = webcrypto.getRandomValues(new Uint8Array(32)); const encodedContent = content.EncodedContent.encode(codec.encode(content$1, { 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 webcrypto.subtle.digest("SHA-256", payload)); const digest = secp.etc.bytesToHex(digestBytes); return { digest, secret, salt, nonce, payload, }; } get contentType() { return ContentTypeRemoteAttachment; } encode(content) { 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) { 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) { return `Can’t display "${content.filename}". This app doesn’t support attachments.`; } shouldPush() { return true; } } export { AttachmentCodec, ContentTypeAttachment, ContentTypeRemoteAttachment, RemoteAttachmentCodec }; //# sourceMappingURL=index.js.map