Encrypted, ephemeral, private memos on atproto

refactor(lexicon): use bytes instead of base64 strings

graham.systems d81141fb 4871d012

verified
Changed files
+66 -71
packages
consumer
lexicon
lexicons
app
cistern
src
types
app
cistern
lexicon
producer
+1
deno.lock
··· 14 14 "npm:@atcute/atproto@^3.1.9": "3.1.9", 15 15 "npm:@atcute/client@^4.0.5": "4.0.5", 16 16 "npm:@atcute/jetstream@^1.1.2": "1.1.2", 17 + "npm:@atcute/lex-cli@*": "2.3.1", 17 18 "npm:@atcute/lex-cli@^2.3.1": "2.3.1", 18 19 "npm:@atcute/lexicons@^1.2.2": "1.2.2", 19 20 "npm:@atcute/tid@^1.0.3": "1.0.3",
+21 -20
packages/consumer/mod.test.ts
··· 5 5 import type { Client, CredentialManager } from "@atcute/client"; 6 6 import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; 7 7 import { now } from "@atcute/tid"; 8 + import type { AppCisternLexiconItem } from "@cistern/lexicon"; 8 9 9 10 // Helper to create a mock Consumer instance 10 11 function createMockConsumer( ··· 194 195 value: { 195 196 $type: "app.cistern.lexicon.item", 196 197 tid: testTid, 197 - ciphertext: encrypted.cipherText, 198 - nonce: encrypted.nonce, 198 + ciphertext: { $bytes: encrypted.cipherText }, 199 + nonce: { $bytes: encrypted.nonce }, 199 200 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 200 201 pubkey: "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 201 - payload: encrypted.content, 202 + payload: { $bytes: encrypted.content }, 202 203 contentLength: encrypted.length, 203 - contentHash: encrypted.hash, 204 - }, 204 + contentHash: { $bytes: encrypted.hash }, 205 + } as AppCisternLexiconItem.Main, 205 206 }, 206 207 ], 207 208 cursor: undefined, ··· 256 257 value: { 257 258 $type: "app.cistern.lexicon.item", 258 259 tid: testTid, 259 - ciphertext: encrypted.cipherText, 260 - nonce: encrypted.nonce, 260 + ciphertext: { $bytes: encrypted.cipherText }, 261 + nonce: { $bytes: encrypted.nonce }, 261 262 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 262 263 pubkey: 263 264 "at://did:plc:test/app.cistern.lexicon.pubkey/different-key", 264 - payload: encrypted.content, 265 + payload: { $bytes: encrypted.content }, 265 266 contentLength: encrypted.length, 266 - contentHash: encrypted.hash, 267 - }, 267 + contentHash: { $bytes: encrypted.hash }, 268 + } as AppCisternLexiconItem.Main, 268 269 }, 269 270 ], 270 271 cursor: undefined, ··· 324 325 value: { 325 326 $type: "app.cistern.lexicon.item", 326 327 tid: tid1, 327 - ciphertext: encrypted1.cipherText, 328 - nonce: encrypted1.nonce, 328 + ciphertext: { $bytes: encrypted1.cipherText }, 329 + nonce: { $bytes: encrypted1.nonce }, 329 330 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 330 331 pubkey: 331 332 "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 332 - payload: encrypted1.content, 333 + payload: { $bytes: encrypted1.content }, 333 334 contentLength: encrypted1.length, 334 - contentHash: encrypted1.hash, 335 - }, 335 + contentHash: { $bytes: encrypted1.hash }, 336 + } as AppCisternLexiconItem.Main, 336 337 }, 337 338 ], 338 339 cursor: "next-page", ··· 348 349 value: { 349 350 $type: "app.cistern.lexicon.item", 350 351 tid: tid2, 351 - ciphertext: encrypted2.cipherText, 352 - nonce: encrypted2.nonce, 352 + ciphertext: { $bytes: encrypted2.cipherText }, 353 + nonce: { $bytes: encrypted2.nonce }, 353 354 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 354 355 pubkey: 355 356 "at://did:plc:test/app.cistern.lexicon.pubkey/key1", 356 - payload: encrypted2.content, 357 + payload: { $bytes: encrypted2.content }, 357 358 contentLength: encrypted2.length, 358 - contentHash: encrypted2.hash, 359 - }, 359 + contentHash: { $bytes: encrypted2.hash }, 360 + } as AppCisternLexiconItem.Main, 360 361 }, 361 362 ], 362 363 cursor: undefined,
+9 -9
packages/consumer/mod.ts
··· 62 62 $type: "app.cistern.lexicon.pubkey", 63 63 name, 64 64 algorithm: "x_wing", 65 - content: keys.publicKey.toBase64(), 65 + content: { $bytes: keys.publicKey.toBase64() }, 66 66 createdAt: new Date().toISOString(), 67 67 }; 68 68 const res = await this.rpc.post("com.atproto.repo.createRecord", { ··· 126 126 if (item.pubkey !== this.keypair.publicKey) continue; 127 127 128 128 const decrypted = decryptText(this.keypair.privateKey, { 129 - nonce: item.nonce, 130 - cipherText: item.ciphertext, 131 - content: item.payload, 132 - hash: item.contentHash, 129 + nonce: item.nonce.$bytes, 130 + cipherText: item.ciphertext.$bytes, 131 + content: item.payload.$bytes, 132 + hash: item.contentHash.$bytes, 133 133 length: item.contentLength, 134 134 }); 135 135 ··· 175 175 } 176 176 177 177 const decrypted = decryptText(this.keypair.privateKey, { 178 - nonce: record.nonce, 179 - cipherText: record.ciphertext, 180 - content: record.payload, 181 - hash: record.contentHash, 178 + nonce: record.nonce.$bytes, 179 + cipherText: record.ciphertext.$bytes, 180 + content: record.payload.$bytes, 181 + hash: record.contentHash.$bytes, 182 182 length: record.contentLength, 183 183 }); 184 184
+8 -10
packages/lexicon/lexicons/app/cistern/lexicon/item.json
··· 25 25 "format": "tid" 26 26 }, 27 27 "ciphertext": { 28 - "type": "string", 29 - "description": "Encapsulated shared ciphertext", 30 - "maxLength": 2000 28 + "type": "bytes", 29 + "description": "Encapsulated shared ciphertext" 31 30 }, 32 31 "nonce": { 33 - "type": "string", 34 - "description": "Base64-encoded nonce used for content encryption", 35 - "maxLength": 32 32 + "type": "bytes", 33 + "description": "Nonce used for content encryption" 36 34 }, 37 35 "algorithm": { 38 36 "type": "string", ··· 45 43 "format": "at-uri" 46 44 }, 47 45 "payload": { 48 - "type": "string", 49 - "description": "Base64-encoded encrypted item contents" 46 + "type": "bytes", 47 + "description": "Encrypted item contents" 50 48 }, 51 49 "contentLength": { 52 50 "type": "integer", 53 51 "description": "Original content length in bytes" 54 52 }, 55 53 "contentHash": { 56 - "type": "string", 57 - "description": "Base64-encoded hash of the decrypted contents. Verify this before accepting the decrypted message. The algorithm is identified under `algorithm`" 54 + "type": "bytes", 55 + "description": "Hash of the decrypted contents. Verify this before accepting the decrypted message. The algorithm is identified under `algorithm`" 58 56 } 59 57 } 60 58 }
+2 -2
packages/lexicon/lexicons/app/cistern/lexicon/pubkey.json
··· 21 21 "description": "KEM algorithm used to generate this key" 22 22 }, 23 23 "content": { 24 - "type": "string", 25 - "description": "Contents of the public key, encoded in base64" 24 + "type": "bytes", 25 + "description": "Contents of the public key" 26 26 }, 27 27 "createdAt": { 28 28 "type": "string",
+7 -13
packages/lexicon/src/types/app/cistern/lexicon/item.ts
··· 14 14 >(), 15 15 /** 16 16 * Encapsulated shared ciphertext 17 - * @maxLength 2000 18 17 */ 19 - ciphertext: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 20 - /*#__PURE__*/ v.stringLength(0, 2000), 21 - ]), 18 + ciphertext: /*#__PURE__*/ v.bytes(), 22 19 /** 23 - * Base64-encoded hash of the decrypted contents. Verify this before accepting the decrypted message. The algorithm is identified under `algorithm` 20 + * Hash of the decrypted contents. Verify this before accepting the decrypted message. The algorithm is identified under `algorithm` 24 21 */ 25 - contentHash: /*#__PURE__*/ v.string(), 22 + contentHash: /*#__PURE__*/ v.bytes(), 26 23 /** 27 24 * Original content length in bytes 28 25 */ 29 26 contentLength: /*#__PURE__*/ v.integer(), 30 27 /** 31 - * Base64-encoded nonce used for content encryption 32 - * @maxLength 32 28 + * Nonce used for content encryption 33 29 */ 34 - nonce: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 35 - /*#__PURE__*/ v.stringLength(0, 32), 36 - ]), 30 + nonce: /*#__PURE__*/ v.bytes(), 37 31 /** 38 - * Base64-encoded encrypted item contents 32 + * Encrypted item contents 39 33 */ 40 - payload: /*#__PURE__*/ v.string(), 34 + payload: /*#__PURE__*/ v.bytes(), 41 35 /** 42 36 * URI to the public key used to encrypt this item 43 37 */
+2 -2
packages/lexicon/src/types/app/cistern/lexicon/pubkey.ts
··· 11 11 */ 12 12 algorithm: /*#__PURE__*/ v.string<"x_wing" | (string & {})>(), 13 13 /** 14 - * Contents of the public key, encoded in base64 14 + * Contents of the public key 15 15 */ 16 - content: /*#__PURE__*/ v.string(), 16 + content: /*#__PURE__*/ v.bytes(), 17 17 createdAt: /*#__PURE__*/ v.datetimeString(), 18 18 /** 19 19 * A memorable name for this public key. Avoid using revealing names, such as "Graham's Macbook"
+10 -9
packages/producer/mod.test.ts
··· 4 4 import type { ProducerParams, PublicKeyOption } from "./types.ts"; 5 5 import type { Client, CredentialManager } from "@atcute/client"; 6 6 import type { Did, Handle, ResourceUri } from "@atcute/lexicons"; 7 + import type { AppCisternLexiconPubkey } from "@cistern/lexicon"; 7 8 8 9 // Helper to create a mock Producer instance 9 10 function createMockProducer( ··· 175 176 $type: "app.cistern.lexicon.pubkey", 176 177 name: "Key 1", 177 178 algorithm: "x_wing", 178 - content: new Uint8Array(32).toBase64(), 179 + content: { $bytes: new Uint8Array(32).toBase64() }, 179 180 createdAt: new Date().toISOString(), 180 - }, 181 + } as AppCisternLexiconPubkey.Main, 181 182 }, 182 183 { 183 184 uri: "at://did:plc:test/app.cistern.lexicon.pubkey/key2", ··· 185 186 $type: "app.cistern.lexicon.pubkey", 186 187 name: "Key 2", 187 188 algorithm: "x_wing", 188 - content: new Uint8Array(32).toBase64(), 189 + content: { $bytes: new Uint8Array(32).toBase64() }, 189 190 createdAt: new Date().toISOString(), 190 - }, 191 + } as AppCisternLexiconPubkey.Main, 191 192 }, 192 193 ], 193 194 cursor: undefined, ··· 216 217 async fn() { 217 218 let callCount = 0; 218 219 const mockRpc = { 219 - get: (endpoint: string, params?: { params?: { cursor?: string } }) => { 220 + get: (endpoint: string, _params?: { params?: { cursor?: string } }) => { 220 221 if (endpoint === "com.atproto.repo.listRecords") { 221 222 callCount++; 222 223 ··· 231 232 $type: "app.cistern.lexicon.pubkey", 232 233 name: "Key 1", 233 234 algorithm: "x_wing", 234 - content: new Uint8Array(32).toBase64(), 235 + content: { $bytes: new Uint8Array(32).toBase64() }, 235 236 createdAt: new Date().toISOString(), 236 - }, 237 + } as AppCisternLexiconPubkey.Main, 237 238 }, 238 239 ], 239 240 cursor: "next-page", ··· 250 251 $type: "app.cistern.lexicon.pubkey", 251 252 name: "Key 2", 252 253 algorithm: "x_wing", 253 - content: new Uint8Array(32).toBase64(), 254 + content: { $bytes: new Uint8Array(32).toBase64() }, 254 255 createdAt: new Date().toISOString(), 255 - }, 256 + } as AppCisternLexiconPubkey.Main, 256 257 }, 257 258 ], 258 259 cursor: undefined,
+6 -6
packages/producer/mod.ts
··· 41 41 publicKey = { 42 42 uri: res.data.uri, 43 43 name: record.name, 44 - content: record.content, 44 + content: record.content.$bytes, 45 45 }; 46 46 } 47 47 ··· 82 82 $type: "app.cistern.lexicon.item", 83 83 tid: now(), 84 84 algorithm: "x_wing-xchacha20_poly1305-sha3_512", 85 - ciphertext: payload.cipherText, 86 - contentHash: payload.hash, 85 + ciphertext: { $bytes: payload.cipherText }, 86 + contentHash: { $bytes: payload.hash }, 87 87 contentLength: payload.length, 88 - nonce: payload.nonce, 89 - payload: payload.content, 88 + nonce: { $bytes: payload.nonce }, 89 + payload: { $bytes: payload.content }, 90 90 pubkey: this.publicKey.uri, 91 91 }; 92 92 ··· 139 139 140 140 yield { 141 141 uri: record.uri, 142 - content: item.content, 142 + content: item.content.$bytes, 143 143 name: item.name, 144 144 }; 145 145 }