Encrypted, ephemeral, private memos on atproto
at main 4.6 kB view raw
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}