keychat-save 1.0.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.
Files changed (3) hide show
  1. package/README.md +53 -0
  2. package/index.mjs +289 -0
  3. package/package.json +22 -0
package/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @keychat/save
2
+
3
+ Save files and sessions to BSV blockchain. Sealed with your key, permanent on chain.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @keychat/save
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ ```bash
14
+ keychat-save init
15
+ ```
16
+
17
+ This creates your identity at `~/.keychat/identity.wif` and shows your BSV address. Fund it with BSV to start saving.
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ # Save content
23
+ keychat-save save "my notes" "content to save permanently"
24
+
25
+ # Save a file
26
+ keychat-save save "backup" --file ./myfile.txt
27
+
28
+ # Pipe content in
29
+ cat session.log | keychat-save save "session log" --stdin
30
+
31
+ # Load all saves
32
+ keychat-save load
33
+
34
+ # Load most recent
35
+ keychat-save load --last
36
+
37
+ # Search
38
+ keychat-save load --search "keyword"
39
+ ```
40
+
41
+ ## How it works
42
+
43
+ Content is sealed with your identity key (ECIES) and broadcast as a transaction on BSV via [KeyChat](https://keychat.pro). Only your key can decrypt it. Saves are permanent and portable — load them on any device with your identity key.
44
+
45
+ Cost: 0.25 Compute (125 sats) minimum, scales with content size.
46
+
47
+ ## Web UI
48
+
49
+ You can also save and load from the browser at [keychat.pro](https://keychat.pro) — click **KeyChain** in the sidebar.
50
+
51
+ ## License
52
+
53
+ MIT
package/index.mjs ADDED
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @keychat/save — Save and load content to BSV blockchain via KeyChat.
4
+ *
5
+ * Usage:
6
+ * keychat-save init Create identity (~/.keychat/identity.wif)
7
+ * keychat-save save "label" "content" Save content to chain
8
+ * keychat-save save "label" --file path Save file to chain
9
+ * keychat-save save "label" --stdin Pipe content in
10
+ * keychat-save load List all saves
11
+ * keychat-save load --last Most recent save
12
+ * keychat-save load --search "keyword" Search saves
13
+ *
14
+ * Identity stored at ~/.keychat/identity.wif
15
+ * Data sealed with your key, permanent on BSV. https://keychat.pro
16
+ */
17
+ import { PrivateKey, Transaction, P2PKH, Script, SatoshisPerKilobyte, EncryptedMessage, PublicKey, Utils } from '@bsv/sdk'
18
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
19
+ import { homedir } from 'node:os'
20
+ import { join } from 'node:path'
21
+
22
+ const CONFIG_DIR = join(homedir(), '.keychat')
23
+ const WIF_PATH = join(CONFIG_DIR, 'identity.wif')
24
+ const BRIDGE = 'https://www.keychat.pro/api/bridge'
25
+ const SAVE_PREFIX = '4b534156' // "KSAV"
26
+ const VERSION = '01'
27
+ const SUB_UNIT_SATS = 125
28
+
29
+ // ── crypto ───────────────────────────────────────────────────────
30
+ function bytesToHex (bytes) {
31
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
32
+ }
33
+ function hexToBytes (hex) {
34
+ const bytes = new Uint8Array(hex.length / 2)
35
+ for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
36
+ return bytes
37
+ }
38
+ function encrypt (plaintext, privKey, pubHex) {
39
+ return EncryptedMessage.encrypt(Utils.toArray(plaintext, 'utf8'), privKey, PublicKey.fromString(pubHex))
40
+ }
41
+ function decrypt (encrypted, privKey) {
42
+ return Utils.toUTF8(EncryptedMessage.decrypt(encrypted, privKey))
43
+ }
44
+
45
+ // ── bridge API ───────────────────────────────────────────────────
46
+ async function getUnspent (address) {
47
+ const res = await fetch(`${BRIDGE}/api/address/${address}/unspent`)
48
+ if (!res.ok) throw new Error(`unspent ${res.status}`)
49
+ const data = await res.json()
50
+ return data.map(u => ({
51
+ txid: u.tx_hash || u.txid,
52
+ vout: u.tx_pos ?? u.vout,
53
+ satoshis: u.value ?? u.satoshis,
54
+ height: u.height
55
+ }))
56
+ }
57
+ async function getTx (txid) {
58
+ const res = await fetch(`${BRIDGE}/api/tx/${txid}`)
59
+ if (!res.ok) throw new Error(`tx ${res.status}`)
60
+ return res.json()
61
+ }
62
+ async function getRawHex (txid) {
63
+ const res = await fetch(`${BRIDGE}/api/tx/${txid}/hex`)
64
+ if (!res.ok) throw new Error(`hex ${res.status}`)
65
+ const ct = res.headers.get('content-type') || ''
66
+ const data = ct.includes('json') ? await res.json() : await res.text()
67
+ return (typeof data === 'string' ? data.replace(/"/g, '') : data.hex)
68
+ }
69
+ async function broadcast (hex) {
70
+ const res = await fetch(`${BRIDGE}/api/broadcast`, {
71
+ method: 'POST',
72
+ headers: { 'Content-Type': 'application/json' },
73
+ body: JSON.stringify({ rawTx: hex })
74
+ })
75
+ const text = await res.text()
76
+ if (!res.ok) throw new Error(`broadcast ${res.status}: ${text}`)
77
+ try {
78
+ const p = JSON.parse(text)
79
+ return p.txid || p.hash || text
80
+ } catch { return text.replace(/"/g, '').trim() }
81
+ }
82
+
83
+ // ── OP_RETURN ────────────────────────────────────────────────────
84
+ function buildOpReturn (parts) {
85
+ let hex = '006a'
86
+ for (const part of parts) {
87
+ const len = part.length / 2
88
+ if (len < 76) {
89
+ hex += len.toString(16).padStart(2, '0')
90
+ } else if (len < 256) {
91
+ hex += '4c' + len.toString(16).padStart(2, '0')
92
+ } else if (len < 65536) {
93
+ hex += '4d' + (len & 0xff).toString(16).padStart(2, '0') + ((len >> 8) & 0xff).toString(16).padStart(2, '0')
94
+ } else {
95
+ hex += '4e'
96
+ hex += (len & 0xff).toString(16).padStart(2, '0')
97
+ hex += ((len >> 8) & 0xff).toString(16).padStart(2, '0')
98
+ hex += ((len >> 16) & 0xff).toString(16).padStart(2, '0')
99
+ hex += ((len >>> 24) & 0xff).toString(16).padStart(2, '0')
100
+ }
101
+ hex += part
102
+ }
103
+ return Script.fromHex(hex)
104
+ }
105
+ function extractParts (scriptHex) {
106
+ let pos = scriptHex.startsWith('006a') ? 4 : scriptHex.startsWith('6a') ? 2 : -1
107
+ if (pos < 0) return null
108
+ const parts = []
109
+ while (pos < scriptHex.length) {
110
+ const op = parseInt(scriptHex.substr(pos, 2), 16)
111
+ pos += 2
112
+ let len
113
+ if (op <= 75) { len = op }
114
+ else if (op === 0x4c) { len = parseInt(scriptHex.substr(pos, 2), 16); pos += 2 }
115
+ else if (op === 0x4d) { len = parseInt(scriptHex.substr(pos, 2), 16) | (parseInt(scriptHex.substr(pos + 2, 2), 16) << 8); pos += 4 }
116
+ else if (op === 0x4e) { len = parseInt(scriptHex.substr(pos, 2), 16) | (parseInt(scriptHex.substr(pos + 2, 2), 16) << 8) | (parseInt(scriptHex.substr(pos + 4, 2), 16) << 16) | (parseInt(scriptHex.substr(pos + 6, 2), 16) << 24); pos += 8 }
117
+ else break
118
+ parts.push(scriptHex.substr(pos, len * 2))
119
+ pos += len * 2
120
+ }
121
+ return parts
122
+ }
123
+
124
+ // ── commands ─────────────────────────────────────────────────────
125
+ async function cmdInit () {
126
+ if (existsSync(WIF_PATH)) {
127
+ const key = PrivateKey.fromWif(readFileSync(WIF_PATH, 'utf8').trim())
128
+ console.log(`Already initialized.`)
129
+ console.log(`Address: ${key.toPublicKey().toAddress()}`)
130
+ console.log(`WIF: ${WIF_PATH}`)
131
+ console.log(`Fund this address with BSV to start saving.`)
132
+ return
133
+ }
134
+ mkdirSync(CONFIG_DIR, { recursive: true })
135
+ const key = PrivateKey.fromRandom()
136
+ writeFileSync(WIF_PATH, key.toWif() + '\n', { mode: 0o600 })
137
+ console.log(`Initialized!`)
138
+ console.log(`Address: ${key.toPublicKey().toAddress()}`)
139
+ console.log(`WIF saved to: ${WIF_PATH}`)
140
+ console.log(`Fund this address with BSV to start saving.`)
141
+ console.log(`\nGet BSV at keychat.pro or any BSV wallet.`)
142
+ }
143
+
144
+ async function cmdSave (label, body) {
145
+ if (!existsSync(WIF_PATH)) { console.error('Run: keychat-save init'); process.exit(1) }
146
+ const key = PrivateKey.fromWif(readFileSync(WIF_PATH, 'utf8').trim())
147
+ const address = key.toPublicKey().toAddress()
148
+ const pubHex = key.toPublicKey().toString()
149
+
150
+ const envelope = JSON.stringify({ kv: 1, kind: 'save', label, body })
151
+ const encrypted = encrypt(envelope, key, pubHex)
152
+ const cipherHex = bytesToHex(encrypted)
153
+ const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0')
154
+ const opReturn = buildOpReturn([SAVE_PREFIX, VERSION, pubHex, cipherHex, timestamp])
155
+
156
+ const utxos = await getUnspent(address)
157
+ const usable = utxos.filter(u => u.satoshis >= 125 && u.satoshis <= 500).sort((a, b) => b.satoshis - a.satoshis)
158
+ if (!usable.length) throw new Error('No Compute UTXOs. Fund your address at keychat.pro and wait for packaging.')
159
+
160
+ const estBytes = 150 + cipherHex.length / 2 + 50
161
+ const rawFee = Math.ceil(estBytes * 100 / 1000)
162
+ const totalCost = Math.ceil(rawFee / SUB_UNIT_SATS) * SUB_UNIT_SATS
163
+ console.log(` Cost: ${totalCost / 500} Compute (${totalCost} sats)`)
164
+
165
+ const picked = []
166
+ let sum = 0
167
+ for (const u of usable) {
168
+ picked.push(u)
169
+ sum += u.satoshis
170
+ if (sum >= totalCost) break
171
+ }
172
+ if (sum < totalCost) throw new Error(`Insufficient Compute. Need ${totalCost / 500}, have ${sum / 500}.`)
173
+
174
+ const tx = new Transaction()
175
+ for (const u of picked) {
176
+ tx.addInput({
177
+ sourceTransaction: Transaction.fromHex(await getRawHex(u.txid)),
178
+ sourceOutputIndex: u.vout,
179
+ unlockingScriptTemplate: new P2PKH().unlock(key)
180
+ })
181
+ }
182
+ tx.addOutput({ lockingScript: opReturn, satoshis: 0 })
183
+ const changeSats = sum - totalCost
184
+ if (changeSats > 0) {
185
+ tx.addOutput({ lockingScript: new P2PKH().lock(address), satoshis: changeSats })
186
+ }
187
+ await tx.sign()
188
+
189
+ const txid = await broadcast(tx.toHex())
190
+ console.log(`Saved to chain!`)
191
+ console.log(` Label: ${label}`)
192
+ console.log(` Size: ${tx.toHex().length / 2} bytes`)
193
+ console.log(` Tx: ${txid}`)
194
+ }
195
+
196
+ async function cmdLoad (opts = {}) {
197
+ if (!existsSync(WIF_PATH)) { console.error('Run: keychat-save init'); process.exit(1) }
198
+ const key = PrivateKey.fromWif(readFileSync(WIF_PATH, 'utf8').trim())
199
+ const address = key.toPublicKey().toAddress()
200
+ const pubHex = key.toPublicKey().toString()
201
+
202
+ const utxos = await getUnspent(address)
203
+ const seenTxids = new Set()
204
+ const saves = []
205
+
206
+ for (const u of utxos) {
207
+ if (seenTxids.has(u.txid)) continue
208
+ seenTxids.add(u.txid)
209
+ try {
210
+ const txData = await getTx(u.txid)
211
+ let opReturnHex = null
212
+ for (const out of (txData.outputs || [])) {
213
+ const script = out.script || out.scriptPubKey?.hex || ''
214
+ if (script.startsWith('006a') || script.startsWith('6a') || out.satoshis === 0) {
215
+ opReturnHex = script; break
216
+ }
217
+ }
218
+ if (!opReturnHex) continue
219
+ const parts = extractParts(opReturnHex)
220
+ if (!parts || parts.length < 5 || parts[0] !== SAVE_PREFIX || parts[1] !== VERSION) continue
221
+ const [,, senderPub, cipherHex, tsHex] = parts
222
+ if (senderPub !== pubHex) continue
223
+ const decrypted = decrypt(Array.from(hexToBytes(cipherHex)), key)
224
+ let label = 'Untitled', body = decrypted
225
+ try {
226
+ const env = JSON.parse(decrypted)
227
+ if (env.kind === 'save') { label = env.label || 'Untitled'; body = env.body || '' }
228
+ } catch {}
229
+ saves.push({ txid: u.txid, label, body, timestamp: parseInt(tsHex, 16) })
230
+ } catch {}
231
+ }
232
+
233
+ saves.sort((a, b) => b.timestamp - a.timestamp)
234
+ if (!saves.length) { console.log('No saves found.'); return }
235
+
236
+ if (opts.last) {
237
+ const s = saves[0]
238
+ console.log(`[${s.label}] — ${new Date(s.timestamp * 1000).toLocaleString()}`)
239
+ console.log(s.body)
240
+ return
241
+ }
242
+ if (opts.search) {
243
+ const q = opts.search.toLowerCase()
244
+ const matches = saves.filter(s => s.label.toLowerCase().includes(q) || s.body.toLowerCase().includes(q))
245
+ if (!matches.length) { console.log(`No saves matching "${opts.search}".`); return }
246
+ for (const s of matches) {
247
+ console.log(`\n--- ${s.label} (${new Date(s.timestamp * 1000).toLocaleString()}) [${s.txid.slice(0, 12)}…] ---`)
248
+ console.log(s.body.length > 500 ? s.body.slice(0, 500) + '…' : s.body)
249
+ }
250
+ return
251
+ }
252
+ for (const s of saves) {
253
+ console.log(`\n--- ${s.label} (${new Date(s.timestamp * 1000).toLocaleString()}) [${s.txid.slice(0, 12)}…] ---`)
254
+ console.log(s.body.length > 500 ? s.body.slice(0, 500) + '…' : s.body)
255
+ }
256
+ }
257
+
258
+ // ── main ─────────────────────────────────────────────────────────
259
+ const [,, cmd, ...args] = process.argv
260
+
261
+ if (cmd === 'init') {
262
+ await cmdInit()
263
+ } else if (cmd === 'save') {
264
+ let label = args[0] || 'Untitled'
265
+ let body
266
+ if (args[1] === '--file') { body = readFileSync(args[2], 'utf8') }
267
+ else if (args[1] === '--stdin') { body = readFileSync(0, 'utf8') }
268
+ else { body = args.slice(1).join(' ') }
269
+ if (!body) { console.error('Usage: keychat-save save "label" "content"'); process.exit(1) }
270
+ await cmdSave(label, body)
271
+ } else if (cmd === 'load') {
272
+ const opts = {}
273
+ if (args.includes('--last')) opts.last = true
274
+ if (args.includes('--search')) opts.search = args[args.indexOf('--search') + 1]
275
+ await cmdLoad(opts)
276
+ } else {
277
+ console.log(`keychat-save — Save to BSV blockchain via KeyChat (keychat.pro)
278
+
279
+ Commands:
280
+ init Create identity (~/.keychat/identity.wif)
281
+ save "label" "content" Save content to chain
282
+ save "label" --file path Save file to chain
283
+ save "label" --stdin Pipe content in
284
+ load List all saves
285
+ load --last Most recent save
286
+ load --search "keyword" Search saves
287
+
288
+ Your data. Your key. Permanent on BSV.`)
289
+ }
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "keychat-save",
3
+ "version": "1.0.0",
4
+ "description": "Save and load files to BSV blockchain via KeyChat",
5
+ "type": "module",
6
+ "bin": {
7
+ "keychat-save": "./index.mjs"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "dependencies": {
13
+ "@bsv/sdk": "^1.0.0"
14
+ },
15
+ "keywords": ["bsv", "blockchain", "keychat", "save", "encrypt", "bitcoin"],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/avaziri93/keychat-save"
20
+ },
21
+ "homepage": "https://keychat.pro"
22
+ }