@iamoberlin/chorus 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Prayer Chain — E2E Encryption
3
+ *
4
+ * Provides private prayer delivery using X25519 Diffie-Hellman key exchange
5
+ * and XSalsa20-Poly1305 authenticated encryption (via tweetnacl).
6
+ *
7
+ * Flow:
8
+ * 1. Each agent derives an X25519 keypair from their Ed25519 Solana wallet
9
+ * 2. X25519 public key is stored on-chain in the Agent account
10
+ * 3. When a prayer is claimed, asker encrypts content using DH shared secret
11
+ * 4. Only the claimer can decrypt (and vice versa for answers)
12
+ *
13
+ * The encryption key derivation is deterministic:
14
+ * Ed25519 signing key → X25519 encryption key (one-way, standard conversion)
15
+ * Same wallet always produces the same encryption keypair.
16
+ */
17
+
18
+ import nacl from "tweetnacl";
19
+ import { Keypair } from "@solana/web3.js";
20
+
21
+ /**
22
+ * Derive an X25519 keypair from an Ed25519 Solana keypair.
23
+ *
24
+ * Ed25519 secret keys are 64 bytes (32-byte seed + 32-byte public key).
25
+ * The seed (first 32 bytes) is hashed with SHA-512 and clamped to produce
26
+ * the X25519 private key. tweetnacl handles this conversion.
27
+ */
28
+ export function deriveEncryptionKeypair(solanaKeypair: Keypair): {
29
+ publicKey: Uint8Array; // 32 bytes — X25519 public key (store on-chain)
30
+ secretKey: Uint8Array; // 32 bytes — X25519 private key (never leaves device)
31
+ } {
32
+ // tweetnacl's box keypair from Ed25519 seed
33
+ // The Ed25519 secret key in Solana is 64 bytes: [seed(32) || pubkey(32)]
34
+ const ed25519SecretKey = solanaKeypair.secretKey; // 64 bytes
35
+
36
+ // Convert Ed25519 keypair to X25519
37
+ const x25519PublicKey = nacl.box.keyPair.fromSecretKey(
38
+ ed25519SecretKeyToX25519(ed25519SecretKey)
39
+ ).publicKey;
40
+
41
+ const x25519SecretKey = ed25519SecretKeyToX25519(ed25519SecretKey);
42
+
43
+ return {
44
+ publicKey: x25519PublicKey,
45
+ secretKey: x25519SecretKey,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Convert Ed25519 secret key (64 bytes) to X25519 secret key (32 bytes).
51
+ * Uses the SHA-512 hash of the seed, clamped per RFC 7748.
52
+ */
53
+ function ed25519SecretKeyToX25519(ed25519SecretKey: Uint8Array): Uint8Array {
54
+ // Hash the 32-byte seed (first half of Ed25519 secret key)
55
+ const seed = ed25519SecretKey.slice(0, 32);
56
+ const hash = nacl.hash(seed); // SHA-512 → 64 bytes
57
+
58
+ // Clamp the first 32 bytes per X25519 spec
59
+ const x25519Key = new Uint8Array(32);
60
+ x25519Key.set(hash.slice(0, 32));
61
+ x25519Key[0] &= 248;
62
+ x25519Key[31] &= 127;
63
+ x25519Key[31] |= 64;
64
+
65
+ return x25519Key;
66
+ }
67
+
68
+ /**
69
+ * Encrypt a message for a specific recipient using DH key exchange.
70
+ *
71
+ * @param plaintext - UTF-8 string to encrypt
72
+ * @param recipientPublicKey - Recipient's X25519 public key (from on-chain Agent account)
73
+ * @param senderSecretKey - Sender's X25519 private key (derived from wallet)
74
+ * @returns Encrypted blob: nonce(24) || ciphertext(len+16)
75
+ */
76
+ export function encryptForRecipient(
77
+ plaintext: string,
78
+ recipientPublicKey: Uint8Array,
79
+ senderSecretKey: Uint8Array,
80
+ ): Uint8Array {
81
+ const message = new TextEncoder().encode(plaintext);
82
+ const nonce = nacl.randomBytes(nacl.box.nonceLength); // 24 bytes
83
+
84
+ const encrypted = nacl.box(message, nonce, recipientPublicKey, senderSecretKey);
85
+ if (!encrypted) {
86
+ throw new Error("Encryption failed");
87
+ }
88
+
89
+ // Pack as: nonce(24) || ciphertext(len + 16 for Poly1305 tag)
90
+ const result = new Uint8Array(nonce.length + encrypted.length);
91
+ result.set(nonce);
92
+ result.set(encrypted, nonce.length);
93
+
94
+ return result;
95
+ }
96
+
97
+ /**
98
+ * Decrypt a message from a specific sender using DH key exchange.
99
+ *
100
+ * @param encryptedBlob - nonce(24) || ciphertext from encryptForRecipient
101
+ * @param senderPublicKey - Sender's X25519 public key (from on-chain Agent account)
102
+ * @param recipientSecretKey - Recipient's X25519 private key (derived from wallet)
103
+ * @returns Decrypted UTF-8 string, or null if decryption fails
104
+ */
105
+ export function decryptFromSender(
106
+ encryptedBlob: Uint8Array,
107
+ senderPublicKey: Uint8Array,
108
+ recipientSecretKey: Uint8Array,
109
+ ): string | null {
110
+ if (encryptedBlob.length < nacl.box.nonceLength + nacl.box.overheadLength) {
111
+ return null; // Too short to contain nonce + tag
112
+ }
113
+
114
+ const nonce = encryptedBlob.slice(0, nacl.box.nonceLength);
115
+ const ciphertext = encryptedBlob.slice(nacl.box.nonceLength);
116
+
117
+ const decrypted = nacl.box.open(ciphertext, nonce, senderPublicKey, recipientSecretKey);
118
+ if (!decrypted) {
119
+ return null; // Authentication failed — wrong key or tampered
120
+ }
121
+
122
+ return new TextDecoder().decode(decrypted);
123
+ }
124
+
125
+ /**
126
+ * Get the X25519 public key bytes suitable for on-chain storage.
127
+ * Returns a 32-element number array for Anchor serialization.
128
+ */
129
+ export function getEncryptionKeyForChain(solanaKeypair: Keypair): number[] {
130
+ const { publicKey } = deriveEncryptionKeypair(solanaKeypair);
131
+ return Array.from(publicKey);
132
+ }