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.
- package/README.md +53 -0
- package/index.mjs +289 -0
- 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
|
+
}
|