@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
JavaScript
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