@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
text/typescript
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;
}
}