Encrypted, ephemeral, private memos on atproto
1import { produceRequirements } from "@cistern/shared";
2import { encryptText } from "@cistern/crypto";
3import type {
4 ProducerOptions,
5 ProducerParams,
6 PublicKeyOption,
7} from "./types.ts";
8import { type Did, parse, type ResourceUri } from "@atcute/lexicons";
9import type { Client } from "@atcute/client";
10import { now } from "@atcute/tid";
11import { type AppCisternMemo, AppCisternPubkey } from "@cistern/lexicon";
12
13/**
14 * Creates a `Producer` instance with all necessary requirements. This is the recommended way to construct a `Producer`.
15 *
16 * @description Resolves the user's DID using Slingshot, instantiates an `@atcute/client` instance, creates an initial session, and returns a new `Producer`. If a pubkey record key is provided, it will be resolved and set as the active key.
17 * @param {ProducerOptions} options - Information for constructing the underlying XRPC client
18 * @returns {Promise<Producer>} A Cistern producer client with an authorized session
19 */
20export async function createProducer(
21 { publicKey: rkey, ...opts }: ProducerOptions,
22): Promise<Producer> {
23 const reqs = await produceRequirements(opts);
24
25 let publicKey: PublicKeyOption | undefined;
26 if (rkey) {
27 const res = await reqs.rpc.get("com.atproto.repo.getRecord", {
28 params: {
29 repo: reqs.miniDoc.did,
30 rkey,
31 collection: "app.cistern.pubkey",
32 },
33 });
34
35 if (!res.ok) {
36 throw new Error(
37 `invalid public key record ID ${publicKey}, got: ${res.status} ${res.data.error}`,
38 );
39 }
40
41 const record = parse(AppCisternPubkey.mainSchema, res.data.value);
42
43 publicKey = {
44 uri: res.data.uri,
45 name: record.name,
46 content: record.content.$bytes,
47 };
48 }
49
50 return new Producer({
51 ...reqs,
52 publicKey,
53 });
54}
55
56/**
57 * A client for encrypting and creating Cistern memos.
58 */
59export class Producer {
60 /** DID of the user this producer acts on behalf of */
61 did: Did;
62
63 /** `@atcute/client` instance with credential manager */
64 rpc: Client;
65
66 /** Partial public key record, used for encrypting items */
67 publicKey?: PublicKeyOption;
68
69 constructor(params: ProducerParams) {
70 this.did = params.miniDoc.did;
71 this.rpc = params.rpc;
72 this.publicKey = params.publicKey;
73 }
74
75 /**
76 * Creates a memo and saves it as a record in the user's PDS.
77 * @param {string} text - The contents of the memo you wish to create
78 */
79 async createMemo(text: string): Promise<ResourceUri> {
80 if (!this.publicKey) {
81 throw new Error(
82 "no public key set; select a public key before creating a memo",
83 );
84 }
85
86 const payload = encryptText(
87 Uint8Array.fromBase64(this.publicKey.content),
88 text,
89 );
90 const record: AppCisternMemo.Main = {
91 $type: "app.cistern.memo",
92 tid: now(),
93 algorithm: "x_wing-xchacha20_poly1305-sha3_512",
94 ciphertext: { $bytes: payload.cipherText },
95 contentHash: { $bytes: payload.hash },
96 contentLength: payload.length,
97 nonce: { $bytes: payload.nonce },
98 payload: { $bytes: payload.content },
99 pubkey: this.publicKey.uri,
100 };
101
102 const res = await this.rpc.post("com.atproto.repo.createRecord", {
103 input: {
104 collection: "app.cistern.memo",
105 repo: this.did,
106 record,
107 },
108 });
109
110 if (!res.ok) {
111 throw new Error(
112 `failed to create new memo: ${res.status} ${res.data.error}`,
113 );
114 }
115
116 return res.data.uri;
117 }
118
119 /**
120 * Lists public keys registered in the user's PDS
121 */
122 async *listPublicKeys(): AsyncGenerator<
123 PublicKeyOption,
124 void,
125 void
126 > {
127 let cursor: string | undefined;
128
129 while (true) {
130 const res = await this.rpc.get("com.atproto.repo.listRecords", {
131 params: {
132 collection: "app.cistern.pubkey",
133 repo: this.did,
134 cursor,
135 },
136 });
137
138 if (!res.ok) {
139 throw new Error(
140 `failed to list public keys: ${res.status} ${res.data.error}`,
141 );
142 }
143
144 cursor = res.data.cursor;
145
146 for (const record of res.data.records) {
147 const memo = parse(AppCisternPubkey.mainSchema, record.value);
148
149 yield {
150 uri: record.uri,
151 content: memo.content.$bytes,
152 name: memo.name,
153 };
154 }
155
156 if (!cursor) return;
157 }
158 }
159
160 /**
161 * Sets a public key as the main encryption key. This is not necessary to use if you instantiated the client with a public key.
162 * @param {PublicKeyOption} key - The key you want to use for encryption
163 */
164 selectPublicKey(key: PublicKeyOption) {
165 this.publicKey = key;
166 }
167}