Encrypted, ephemeral, private memos on atproto

implement basic crypto package

graham.systems 1df8c7ef f3ec2608

verified
+185
+58
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "jsr:@noble/ciphers@*": "2.0.1", 5 + "jsr:@noble/ciphers@^2.0.1": "2.0.1", 6 + "jsr:@noble/curves@2.0": "2.0.1", 7 + "jsr:@noble/hashes@*": "2.0.1", 8 + "jsr:@noble/hashes@2": "2.0.1", 9 + "jsr:@noble/hashes@2.0": "2.0.1", 10 + "jsr:@noble/hashes@^2.0.1": "2.0.1", 11 + "jsr:@noble/post-quantum@*": "0.5.2", 12 + "jsr:@noble/post-quantum@~0.5.2": "0.5.2", 13 + "jsr:@std/assert@^1.0.14": "1.0.15", 14 + "jsr:@std/expect@^1.0.17": "1.0.17", 15 + "jsr:@std/internal@^1.0.10": "1.0.12", 16 + "jsr:@std/internal@^1.0.12": "1.0.12", 4 17 "npm:@atproto/lexicon@~0.5.1": "0.5.1" 5 18 }, 19 + "jsr": { 20 + "@noble/ciphers@2.0.1": { 21 + "integrity": "1d28df773a29684c85844d27eefbb7cad3e4ce62849b63dae3024baf66cf769f" 22 + }, 23 + "@noble/curves@2.0.1": { 24 + "integrity": "21ef41d207a203f60ba37a4fdcbc4f4a545b10c5dab7f293889f18292f81ab23", 25 + "dependencies": [ 26 + "jsr:@noble/hashes@2" 27 + ] 28 + }, 29 + "@noble/hashes@2.0.1": { 30 + "integrity": "e0e908292a0bf91099cf8ba0720a1647cef82ab38b588815b5e9535b4ff4d7bb" 31 + }, 32 + "@noble/post-quantum@0.5.2": { 33 + "integrity": "e15f0a636f8d3b39ad584fe9b8a7dd203a6330e9ec48728be7edd6af8e2c7136", 34 + "dependencies": [ 35 + "jsr:@noble/curves", 36 + "jsr:@noble/hashes@2.0" 37 + ] 38 + }, 39 + "@std/assert@1.0.15": { 40 + "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b", 41 + "dependencies": [ 42 + "jsr:@std/internal@^1.0.12" 43 + ] 44 + }, 45 + "@std/expect@1.0.17": { 46 + "integrity": "316b47dd65c33e3151344eb3267bf42efba17d1415425f07ed96185d67fc04d9", 47 + "dependencies": [ 48 + "jsr:@std/assert", 49 + "jsr:@std/internal@^1.0.10" 50 + ] 51 + }, 52 + "@std/internal@1.0.12": { 53 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 54 + } 55 + }, 6 56 "npm": { 7 57 "@atproto/common-web@0.4.3": { 8 58 "integrity": "sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==", ··· 47 97 }, 48 98 "workspace": { 49 99 "members": { 100 + "packages/crypto": { 101 + "dependencies": [ 102 + "jsr:@noble/ciphers@^2.0.1", 103 + "jsr:@noble/hashes@^2.0.1", 104 + "jsr:@noble/post-quantum@~0.5.2", 105 + "jsr:@std/expect@^1.0.17" 106 + ] 107 + }, 50 108 "packages/lexicon": { 51 109 "dependencies": [ 52 110 "npm:@atproto/lexicon@~0.5.1"
+12
packages/crypto/deno.jsonc
··· 1 + { 2 + "name": "@cistern/crypto", 3 + "exports": { 4 + ".": "mod.ts" 5 + }, 6 + "imports": { 7 + "@noble/ciphers": "jsr:@noble/ciphers@^2.0.1", 8 + "@noble/hashes": "jsr:@noble/hashes@^2.0.1", 9 + "@noble/post-quantum": "jsr:@noble/post-quantum@^0.5.2", 10 + "@std/expect": "jsr:@std/expect@^1.0.17" 11 + } 12 + }
+4
packages/crypto/mod.ts
··· 1 + export * from "./src/keys.ts"; 2 + export * from "./src/encrypt.ts"; 3 + export * from "./src/decrypt.ts"; 4 + export * from "./src/types.ts";
+16
packages/crypto/src/decrypt.test.ts
··· 1 + import { expect } from "@std/expect"; 2 + import { generateKeys } from "./keys.ts"; 3 + import { encryptText } from "./encrypt.ts"; 4 + import { decryptText } from "./decrypt.ts"; 5 + 6 + Deno.test({ 7 + name: "decrypts an encrypted value", 8 + fn() { 9 + const keys = generateKeys(); 10 + const text = "Hello, world!"; 11 + const encrypted = encryptText(keys.publicKey, text); 12 + const decrypted = decryptText(keys.secretKey, encrypted); 13 + 14 + expect(decrypted).toEqual(text); 15 + }, 16 + });
+17
packages/crypto/src/decrypt.ts
··· 1 + import { XWing } from "@noble/post-quantum/hybrid.js"; 2 + import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; 3 + import type { EncryptedPayload } from "./types.ts"; 4 + 5 + export function decryptText( 6 + secretKey: Uint8Array, 7 + payload: EncryptedPayload, 8 + ): string { 9 + const cipherText = Uint8Array.fromBase64(payload.cipherText); 10 + const nonce = Uint8Array.fromBase64(payload.nonce); 11 + const content = Uint8Array.fromBase64(payload.content); 12 + const sharedSecret = XWing.decapsulate(cipherText, secretKey); 13 + const cipher = xchacha20poly1305(sharedSecret, nonce); 14 + const decrypted = cipher.decrypt(content); 15 + 16 + return new TextDecoder().decode(decrypted); 17 + }
+19
packages/crypto/src/encrypt.test.ts
··· 1 + import { expect } from "@std/expect"; 2 + import { generateKeys } from "./keys.ts"; 3 + import { encryptText } from "./encrypt.ts"; 4 + 5 + Deno.test({ 6 + name: "generates non-empty encrypted payload", 7 + fn() { 8 + const keys = generateKeys(); 9 + const text = "Hello, world!"; 10 + const result = encryptText(keys.publicKey, text); 11 + const entries = Array.from(Object.values(result)); 12 + 13 + expect(entries).toHaveLength(4); 14 + 15 + for (const [key, val] of entries) { 16 + expect(val.length, `${key} is empty`).toBeGreaterThan(0); 17 + } 18 + }, 19 + });
+24
packages/crypto/src/encrypt.ts
··· 1 + import { XWing } from "@noble/post-quantum/hybrid.js"; 2 + import { sha256 } from "@noble/hashes/sha2.js"; 3 + import { xchacha20poly1305 } from "@noble/ciphers/chacha.js"; 4 + import { randomBytes } from "@noble/hashes/utils.js"; 5 + import type { EncryptedPayload } from "./types.ts"; 6 + 7 + export function encryptText( 8 + publicKey: Uint8Array, 9 + text: string, 10 + ): EncryptedPayload { 11 + const { cipherText, sharedSecret } = XWing.encapsulate(publicKey); 12 + const nonce = randomBytes(24); 13 + const contentBytes = new TextEncoder().encode(text); 14 + const cipher = xchacha20poly1305(sharedSecret, nonce); 15 + const content = cipher.encrypt(contentBytes); 16 + const hash = sha256(content); 17 + 18 + return { 19 + cipherText: cipherText.toBase64(), 20 + content: content.toBase64(), 21 + nonce: nonce.toBase64(), 22 + hash: hash.toBase64(), 23 + }; 24 + }
+16
packages/crypto/src/keys.test.ts
··· 1 + import { expect } from "@std/expect"; 2 + import { generateKeys } from "./keys.ts"; 3 + 4 + Deno.test({ 5 + name: "generates non-empty keys", 6 + fn() { 7 + const keys = generateKeys(); 8 + const entries = Array.from(Object.entries(keys)); 9 + 10 + expect(entries).toHaveLength(2); 11 + 12 + for (const [key, val] of entries) { 13 + expect(val.length, `${key} is empty`).toBeGreaterThan(0); 14 + } 15 + }, 16 + });
+13
packages/crypto/src/keys.ts
··· 1 + import { XWing } from "@noble/post-quantum/hybrid.js"; 2 + 3 + export function generateKeys() { 4 + return XWing.keygen(); 5 + } 6 + 7 + export function serializeKey(key: Uint8Array) { 8 + return key.toBase64(); 9 + } 10 + 11 + export function deserializeKey(key: string) { 12 + return Uint8Array.fromBase64(key); 13 + }
+6
packages/crypto/src/types.ts
··· 1 + export interface EncryptedPayload { 2 + cipherText: string; 3 + content: string; 4 + nonce: string; 5 + hash: string; 6 + }