Encrypted, ephemeral, private memos on atproto
1import {
2 produceRequirements,
3 type XRPCProcedures,
4 type XRPCQueries,
5} from "@cistern/shared";
6import { decryptText, generateKeys } from "@cistern/crypto";
7import { generateRandomName } from "@puregarlic/randimal";
8import {
9 is,
10 isResourceUri,
11 parse,
12 type RecordKey,
13 type ResourceUri,
14} from "@atcute/lexicons";
15import { JetstreamSubscription } from "@atcute/jetstream";
16import type { Did } from "@atcute/lexicons/syntax";
17import type { Client } from "@atcute/client";
18import { AppCisternMemo, type AppCisternPubkey } from "@cistern/lexicon";
19import type {
20 ConsumerOptions,
21 ConsumerParams,
22 DecryptedMemo,
23 LocalKeyPair,
24} from "./types.ts";
25
26/**
27 * Client for generating keys and decoding Cistern memos.
28 */
29export class Consumer {
30 /** DID of the user this consumer acts on behalf of */
31 did: Did;
32
33 /** `@atcute/client` instance with credential manager */
34 rpc: Client<XRPCQueries, XRPCProcedures>;
35
36 /** Private key used for decrypting and the AT URI of its associated public key */
37 keypair?: LocalKeyPair;
38
39 constructor(params: ConsumerParams) {
40 this.did = params.miniDoc.did;
41 this.keypair = params.options.keypair
42 ? {
43 privateKey: Uint8Array.fromBase64(params.options.keypair.privateKey),
44 publicKey: params.options.keypair.publicKey as ResourceUri,
45 }
46 : undefined;
47 this.rpc = params.rpc;
48 }
49
50 /**
51 * Generates a key pair, uploading the public key to PDS and returning the pair.
52 */
53 async generateKeyPair(): Promise<LocalKeyPair> {
54 if (this.keypair) {
55 throw new Error("client already has a key pair");
56 }
57
58 const keys = generateKeys();
59 const name = await generateRandomName();
60
61 const record: AppCisternPubkey.Main = {
62 $type: "app.cistern.pubkey",
63 name,
64 algorithm: "x_wing",
65 content: { $bytes: keys.publicKey.toBase64() },
66 createdAt: new Date().toISOString(),
67 };
68 const res = await this.rpc.post("com.atproto.repo.createRecord", {
69 input: {
70 collection: "app.cistern.pubkey",
71 repo: this.did,
72 record,
73 },
74 });
75
76 if (!res.ok) {
77 throw new Error(
78 `failed to save public key: ${res.status} ${res.data.error}`,
79 );
80 }
81
82 const keypair = {
83 privateKey: keys.secretKey,
84 publicKey: res.data.uri,
85 };
86
87 this.keypair = keypair;
88
89 return keypair;
90 }
91
92 /**
93 * Asynchronously iterate through memos in the user's PDS
94 */
95 async *listMemos(): AsyncGenerator<
96 DecryptedMemo,
97 void,
98 undefined
99 > {
100 if (!this.keypair) {
101 throw new Error("no key pair set; generate a key before listing memos");
102 }
103
104 let cursor: string | undefined;
105
106 while (true) {
107 const res = await this.rpc.get("com.atproto.repo.listRecords", {
108 params: {
109 collection: "app.cistern.memo",
110 repo: this.did,
111 cursor,
112 },
113 });
114
115 if (!res.ok) {
116 throw new Error(
117 `failed to list memos: ${res.status} ${res.data.error}`,
118 );
119 }
120
121 cursor = res.data.cursor;
122
123 for (const record of res.data.records) {
124 const memo = parse(AppCisternMemo.mainSchema, record.value);
125
126 if (memo.pubkey !== this.keypair.publicKey) continue;
127
128 const decrypted = decryptText(this.keypair.privateKey, {
129 nonce: memo.nonce.$bytes,
130 cipherText: memo.ciphertext.$bytes,
131 content: memo.payload.$bytes,
132 hash: memo.contentHash.$bytes,
133 length: memo.contentLength,
134 });
135
136 yield {
137 key: record.uri.split("/").pop() as RecordKey,
138 tid: memo.tid,
139 text: decrypted,
140 };
141 }
142
143 if (!cursor) return;
144 }
145 }
146
147 /**
148 * Subscribes to the Jetstreams for the user's memos. Pass `"stop"` into `subscription.next(...)` to cancel
149 * @todo Allow specifying Jetstream endpoint
150 */
151 async *subscribeToMemos(): AsyncGenerator<
152 DecryptedMemo,
153 void,
154 "stop" | undefined
155 > {
156 if (!this.keypair) {
157 throw new Error("no key pair set; generate a key before subscribing");
158 }
159
160 const subscription = new JetstreamSubscription({
161 url: "wss://jetstream2.us-east.bsky.network",
162 wantedCollections: ["app.cistern.memo"],
163 wantedDids: [this.did],
164 });
165
166 for await (const event of subscription) {
167 if (event.kind === "commit" && event.commit.operation === "create") {
168 const record = event.commit.record;
169
170 if (!is(AppCisternMemo.mainSchema, record)) {
171 continue;
172 }
173
174 if (record.pubkey !== this.keypair.publicKey) {
175 continue;
176 }
177
178 const decrypted = decryptText(this.keypair.privateKey, {
179 nonce: record.nonce.$bytes,
180 cipherText: record.ciphertext.$bytes,
181 content: record.payload.$bytes,
182 hash: record.contentHash.$bytes,
183 length: record.contentLength,
184 });
185
186 const command = yield {
187 key: event.commit.rkey,
188 tid: record.tid,
189 text: decrypted,
190 };
191
192 if (command === "stop") return;
193 }
194 }
195 }
196
197 /**
198 * Deletes a memo from the user's PDS by record key.
199 */
200 async deleteMemo(key: RecordKey) {
201 const res = await this.rpc.post("com.atproto.repo.deleteRecord", {
202 input: {
203 collection: "app.cistern.memo",
204 repo: this.did,
205 rkey: key,
206 },
207 });
208
209 if (!res.ok) {
210 throw new Error(
211 `failed to delete memo ${key}: ${res.status} ${res.data.error}`,
212 );
213 }
214 }
215}
216
217/**
218 * Creates a `Consumer` instance with all necessary requirements. This is the recommended way to construct a `Consumer`.
219 *
220 * @description Resolves the user's DID using Slingshot, instantiates an `@atcute/client` instance, creates an initial session, and then returns a new Consumer.
221 * @param {ConsumerOptions} options - Information for constructing the underlying XRPC client
222 * @returns {Promise<Consumer>} A Cistern consumer client with an authorized session
223 */
224export async function createConsumer(
225 options: ConsumerOptions,
226): Promise<Consumer> {
227 const reqs = await produceRequirements(options);
228
229 if (options.keypair && !isResourceUri(options.keypair.publicKey)) {
230 throw new Error("provided public key is not a valid AT URI");
231 }
232
233 return new Consumer(reqs);
234}