keychat-save 1.5.0 → 1.6.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 +16 -0
- package/index.mjs +3 -0
- package/package.json +1 -1
- package/repo.mjs +160 -10
package/README.md
CHANGED
|
@@ -98,8 +98,24 @@ keychat-save keycloud get <txid> ~/Downloads
|
|
|
98
98
|
|
|
99
99
|
# What you have uploaded from this machine
|
|
100
100
|
keychat-save keycloud list
|
|
101
|
+
|
|
102
|
+
# Publish identifiers for everything already on chain — see below
|
|
103
|
+
keychat-save keycloud catalog
|
|
101
104
|
```
|
|
102
105
|
|
|
106
|
+
### Listing without downloading
|
|
107
|
+
|
|
108
|
+
Metadata travels in its own encrypted push, separate from the file. A listing
|
|
109
|
+
therefore costs kilobytes: names, sizes and image thumbnails are read without
|
|
110
|
+
moving a single file body, and the bodies are fetched only when you open one.
|
|
111
|
+
|
|
112
|
+
Anything uploaded before that split has its metadata sealed inside its own body,
|
|
113
|
+
so listing it the old way meant downloading it. `keycloud catalog` fixes that in
|
|
114
|
+
one small transaction: it publishes the identifiers you already hold locally —
|
|
115
|
+
names, sizes, hashes, and your RepoChain snapshots too — encrypted to your key.
|
|
116
|
+
Any device with that key then lists your whole history for a few KB. Cataloguing
|
|
117
|
+
51 items cost 2 Compute.
|
|
118
|
+
|
|
103
119
|
Bytes are encrypted directly — never base64, which would add 33% to the size and therefore 33% to
|
|
104
120
|
the cost. The file's sha256 travels with it and is checked on the way back, so a truncated fetch
|
|
105
121
|
fails loudly instead of writing a corrupt file.
|
package/index.mjs
CHANGED
|
@@ -506,6 +506,9 @@ KEYCLOUD — files on chain, sealed to your own key:
|
|
|
506
506
|
keycloud put <file> Upload one file in a single transaction
|
|
507
507
|
keycloud get <txid> <dest> Fetch it back (sha256 verified before writing)
|
|
508
508
|
keycloud list Files uploaded from this machine
|
|
509
|
+
keycloud catalog Publish identifiers for everything already on
|
|
510
|
+
chain, so any device can list it without
|
|
511
|
+
downloading a single file
|
|
509
512
|
|
|
510
513
|
Each snapshot is ONE transaction. A delta continues the chain automatically and
|
|
511
514
|
carries the ordered txids of every ancestor, so a single txid restores everything.
|
package/package.json
CHANGED
package/repo.mjs
CHANGED
|
@@ -907,11 +907,68 @@ async function restore (txid, dest) {
|
|
|
907
907
|
// transaction no miner will accept.
|
|
908
908
|
const CLOUD_PREFIX = '4b434c44' // "KCLD"
|
|
909
909
|
const CLOUD_INDEX = 'keycloud.json'
|
|
910
|
+
// v2 SPLITS METADATA OUT OF THE FILE'S CIPHERTEXT.
|
|
911
|
+
//
|
|
912
|
+
// v1 [KCLD, 01, pub, encrypt(meta + bytes), ts]
|
|
913
|
+
// v2 [KCLD, 02, pub, encrypt(meta), encrypt(bytes), ts]
|
|
914
|
+
//
|
|
915
|
+
// v1 sealed the filename INSIDE the file's own ciphertext, so listing what you
|
|
916
|
+
// own meant fetching and decrypting every byte you had ever uploaded — ten
|
|
917
|
+
// 10 MB files is a 100 MB download to draw ten labels. A file browser is not
|
|
918
|
+
// buildable on that.
|
|
919
|
+
//
|
|
920
|
+
// In v2 the meta push is a few hundred bytes plus an optional thumbnail, so the
|
|
921
|
+
// bridge can index and serve it alone and the client renders a whole grid
|
|
922
|
+
// without touching a single file body. The bytes are fetched only when someone
|
|
923
|
+
// opens something.
|
|
924
|
+
//
|
|
925
|
+
// BOTH PUSHES STAY ENCRYPTED to the owner's key. Filenames never appear in
|
|
926
|
+
// plaintext on chain — the point is that only the holder of the key learns
|
|
927
|
+
// anything, and a filename is often the most revealing part of a file.
|
|
928
|
+
//
|
|
929
|
+
// v1 files stay readable: cloudGet accepts either shape.
|
|
930
|
+
const CLOUD_VERSION_V1 = '01'
|
|
931
|
+
const CLOUD_VERSION = '02'
|
|
910
932
|
// Policy ceiling for a single transaction. The payload is the whole tx, so this
|
|
911
933
|
// is deliberately conservative against the published 100 MB: envelope, ECIES
|
|
912
934
|
// overhead and the funding input all sit inside the same limit.
|
|
913
935
|
const CLOUD_MAX_SINGLE_TX = 95 * 1024 * 1024
|
|
914
936
|
|
|
937
|
+
const MIME_BY_EXT = {
|
|
938
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
939
|
+
'.webp': 'image/webp', '.heic': 'image/heic', '.tiff': 'image/tiff', '.bmp': 'image/bmp',
|
|
940
|
+
'.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
|
|
941
|
+
'.json': 'application/json', '.csv': 'text/csv', '.zip': 'application/zip',
|
|
942
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', '.wav': 'audio/wav'
|
|
943
|
+
}
|
|
944
|
+
const mimeFor = name => MIME_BY_EXT[path.extname(name).toLowerCase()] || 'application/octet-stream'
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* A 256px JPEG preview, base64, for the file grid to render without ever
|
|
948
|
+
* fetching the original — the same reason Finder feels instant.
|
|
949
|
+
*
|
|
950
|
+
* Uses `sips`, which ships with macOS, rather than adding an image dependency
|
|
951
|
+
* to a package whose only dep is @bsv/sdk. Every failure path returns null and
|
|
952
|
+
* the grid falls back to a type icon: a missing thumbnail must never be able to
|
|
953
|
+
* fail an upload.
|
|
954
|
+
*/
|
|
955
|
+
function makeThumb (abs, mime) {
|
|
956
|
+
if (!mime.startsWith('image/')) return null
|
|
957
|
+
const tmp = path.join(TMP, `kcthumb-${Math.random().toString(36).slice(2)}.jpg`)
|
|
958
|
+
try {
|
|
959
|
+
sh('sips', ['-Z', '256', '-s', 'format', 'jpeg', '-s', 'formatOptions', '60', abs, '--out', tmp],
|
|
960
|
+
{ stdio: 'ignore' })
|
|
961
|
+
const b = fs.readFileSync(tmp)
|
|
962
|
+
// Sanity bound: a "thumbnail" bigger than this is not a thumbnail, and it
|
|
963
|
+
// would bloat the very push that exists to stay small.
|
|
964
|
+
return b.length > 64 * 1024 ? null : b.toString('base64')
|
|
965
|
+
} catch {
|
|
966
|
+
return null
|
|
967
|
+
} finally {
|
|
968
|
+
try { fs.unlinkSync(tmp) } catch { /* never existed */ }
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
915
972
|
function recordCloudFile (rec) {
|
|
916
973
|
const idx = path.join(CONFIG_DIR, CLOUD_INDEX)
|
|
917
974
|
let all = []
|
|
@@ -940,25 +997,39 @@ async function cloudPut (filePath, { dry = false } = {}) {
|
|
|
940
997
|
`command yet — nothing was spent.`)
|
|
941
998
|
}
|
|
942
999
|
const sha = createHash('sha256').update(bytes).digest('hex')
|
|
1000
|
+
const name = path.basename(abs)
|
|
1001
|
+
const mime = mimeFor(name)
|
|
1002
|
+
const thumb = makeThumb(abs, mime)
|
|
943
1003
|
const meta = {
|
|
944
|
-
v:
|
|
1004
|
+
v: 2,
|
|
945
1005
|
kind: 'file',
|
|
946
|
-
name
|
|
1006
|
+
name,
|
|
947
1007
|
size: bytes.length,
|
|
948
1008
|
sha256: sha,
|
|
949
|
-
|
|
1009
|
+
mime,
|
|
1010
|
+
at: Math.floor(Date.now() / 1000),
|
|
1011
|
+
...(thumb ? { thumb } : {})
|
|
950
1012
|
}
|
|
951
|
-
console.log(`${
|
|
1013
|
+
console.log(`${name} ${(bytes.length / 1048576).toFixed(2)} MB ${mime}` +
|
|
1014
|
+
(thumb ? ` thumb ${(Buffer.byteLength(thumb, 'base64') / 1024).toFixed(1)} KB` : ''))
|
|
952
1015
|
console.log(`sha256 ${sha}`)
|
|
953
1016
|
|
|
954
1017
|
process.stdout.write('encrypting ... ')
|
|
955
1018
|
const t = Date.now()
|
|
956
|
-
const
|
|
957
|
-
|
|
1019
|
+
const pub = PublicKey.fromString(pubHex)
|
|
1020
|
+
// TWO PUSHES. The meta push is what the bridge indexes and the file grid
|
|
1021
|
+
// reads; the file push is fetched only when someone opens the file. Both are
|
|
1022
|
+
// sealed to the same key — a filename is often the most revealing part of a
|
|
1023
|
+
// file, so it never travels in plaintext.
|
|
1024
|
+
const metaCipher = EncryptedMessage.encrypt(
|
|
1025
|
+
Array.from(Buffer.from(JSON.stringify(meta), 'utf8')), k, pub
|
|
958
1026
|
)
|
|
959
|
-
|
|
1027
|
+
const fileCipher = EncryptedMessage.encrypt(Array.from(bytes), k, pub)
|
|
1028
|
+
console.log(`${((Date.now() - t) / 1000).toFixed(1)}s ` +
|
|
1029
|
+
`meta ${(metaCipher.length / 1024).toFixed(1)} KB, file ${(fileCipher.length / 1048576).toFixed(2)} MB`)
|
|
960
1030
|
const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0')
|
|
961
|
-
const scriptHex = buildOpReturn(
|
|
1031
|
+
const scriptHex = buildOpReturn(
|
|
1032
|
+
[CLOUD_PREFIX, CLOUD_VERSION, pubHex, hex(metaCipher), hex(fileCipher), timestamp])
|
|
962
1033
|
|
|
963
1034
|
const res = await publishWithRetry(scriptHex, k, address, lockingScript, { dry })
|
|
964
1035
|
if (dry) {
|
|
@@ -993,8 +1064,18 @@ async function cloudGet (txid, dest) {
|
|
|
993
1064
|
}
|
|
994
1065
|
if (!parts || parts[0] !== CLOUD_PREFIX) throw new Error(`${txid} is not a KCLD keycloud transaction`)
|
|
995
1066
|
if (parts[2] !== pubHex) throw new Error(`${txid} is not encrypted to this identity`)
|
|
996
|
-
|
|
997
|
-
|
|
1067
|
+
|
|
1068
|
+
// BOTH SHAPES. v1 sealed meta and bytes in one ciphertext; v2 splits them so
|
|
1069
|
+
// the meta can be indexed and listed without moving the file. Files uploaded
|
|
1070
|
+
// before the split must keep opening — the version byte says which is which.
|
|
1071
|
+
const dec = p => Buffer.from(EncryptedMessage.decrypt(Array.from(Buffer.from(p, 'hex')), k))
|
|
1072
|
+
let meta, bytes
|
|
1073
|
+
if (parts[1] === CLOUD_VERSION_V1) {
|
|
1074
|
+
({ meta, tar: bytes } = unframePayload(dec(parts[3])))
|
|
1075
|
+
} else {
|
|
1076
|
+
meta = JSON.parse(dec(parts[3]).toString('utf8'))
|
|
1077
|
+
bytes = dec(parts[4])
|
|
1078
|
+
}
|
|
998
1079
|
|
|
999
1080
|
const got = createHash('sha256').update(bytes).digest('hex')
|
|
1000
1081
|
if (meta.sha256 && got !== meta.sha256) {
|
|
@@ -1008,6 +1089,73 @@ async function cloudGet (txid, dest) {
|
|
|
1008
1089
|
console.log(`wrote ${out}`)
|
|
1009
1090
|
}
|
|
1010
1091
|
|
|
1092
|
+
/**
|
|
1093
|
+
* Publish a CATALOG: one small transaction carrying identifiers for things
|
|
1094
|
+
* already on chain.
|
|
1095
|
+
*
|
|
1096
|
+
* The backfill problem: everything uploaded before the v2 split sealed its
|
|
1097
|
+
* metadata inside its own body, so listing a v1 item means downloading it —
|
|
1098
|
+
* 34 MB to read a repo name. The bridge cannot help, because it cannot decrypt.
|
|
1099
|
+
* Re-uploading to get v2 metadata would cost thousands of Compute.
|
|
1100
|
+
*
|
|
1101
|
+
* Arash's fix, and it is the right one: send the IDENTIFIERS as their own
|
|
1102
|
+
* delta. We already hold every name, size and hash in the local indexes
|
|
1103
|
+
* (repos.json, keycloud.json) — the expensive part was only ever getting them
|
|
1104
|
+
* back OUT of chain. One catalog transaction publishes the lot, encrypted to
|
|
1105
|
+
* the same key, and any device can then list a full history without moving a
|
|
1106
|
+
* single body.
|
|
1107
|
+
*
|
|
1108
|
+
* Shipped AS a KCLD v2 envelope on purpose: the bridge already indexes those
|
|
1109
|
+
* and serves their meta push, so this needs no new prefix, no new endpoint and
|
|
1110
|
+
* no new indexer. `kind: 'catalog'` tells a reader to merge the entries rather
|
|
1111
|
+
* than render a file.
|
|
1112
|
+
*/
|
|
1113
|
+
async function cloudCatalog ({ dry = false } = {}) {
|
|
1114
|
+
const k = key()
|
|
1115
|
+
const address = k.toPublicKey().toAddress()
|
|
1116
|
+
const pubHex = k.toPublicKey().toString()
|
|
1117
|
+
const lockingScript = new P2PKH().lock(address)
|
|
1118
|
+
|
|
1119
|
+
const read = f => { try { return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, f), 'utf8')) } catch { return [] } }
|
|
1120
|
+
const entries = []
|
|
1121
|
+
for (const r of read('repos.json')) {
|
|
1122
|
+
if (!r.txid) continue
|
|
1123
|
+
entries.push({ t: r.txid, k: 'repo', n: r.repo, h: r.head, s: r.tarSize ?? null, a: r.at ?? null, c: r.cost ?? null })
|
|
1124
|
+
}
|
|
1125
|
+
for (const f of read(CLOUD_INDEX)) {
|
|
1126
|
+
if (!f.txid) continue
|
|
1127
|
+
entries.push({ t: f.txid, k: 'file', n: f.name, m: f.mime ?? null, s: f.size ?? null, a: f.at ?? null, c: f.cost ?? null })
|
|
1128
|
+
}
|
|
1129
|
+
if (!entries.length) throw new Error('nothing to catalog — repos.json and keycloud.json are both empty')
|
|
1130
|
+
|
|
1131
|
+
const meta = { v: 2, kind: 'catalog', at: Math.floor(Date.now() / 1000), count: entries.length, entries }
|
|
1132
|
+
const body = Buffer.from(JSON.stringify(meta), 'utf8')
|
|
1133
|
+
console.log(`catalog ${entries.length} entries ${(body.length / 1024).toFixed(1)} KB`)
|
|
1134
|
+
for (const kind of ['repo', 'file']) {
|
|
1135
|
+
const n = entries.filter(e => e.k === kind).length
|
|
1136
|
+
if (n) console.log(` ${n} ${kind}${n === 1 ? '' : 's'}`)
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
process.stdout.write('encrypting ... ')
|
|
1140
|
+
const pub = PublicKey.fromString(pubHex)
|
|
1141
|
+
const metaCipher = EncryptedMessage.encrypt(Array.from(body), k, pub)
|
|
1142
|
+
// The file push is deliberately empty-but-present: a catalog has no body, and
|
|
1143
|
+
// keeping the six-push shape means every existing v2 reader parses it without
|
|
1144
|
+
// a special case.
|
|
1145
|
+
const fileCipher = EncryptedMessage.encrypt(Array.from(Buffer.alloc(0)), k, pub)
|
|
1146
|
+
console.log(`meta ${(metaCipher.length / 1024).toFixed(1)} KB`)
|
|
1147
|
+
const timestamp = Math.floor(Date.now() / 1000).toString(16).padStart(8, '0')
|
|
1148
|
+
const scriptHex = buildOpReturn(
|
|
1149
|
+
[CLOUD_PREFIX, CLOUD_VERSION, pubHex, hex(metaCipher), hex(fileCipher), timestamp])
|
|
1150
|
+
|
|
1151
|
+
const res = await publishWithRetry(scriptHex, k, address, lockingScript, { dry })
|
|
1152
|
+
if (dry) {
|
|
1153
|
+
console.log(`\n--dry: nothing broadcast\n payload ${res.txid}`)
|
|
1154
|
+
return
|
|
1155
|
+
}
|
|
1156
|
+
console.log(`\nCATALOG ${entries.length} entries -> ${res.txid} (${res.cost} Compute)`)
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1011
1159
|
function cloudList () {
|
|
1012
1160
|
let all = []
|
|
1013
1161
|
try { all = JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, CLOUD_INDEX), 'utf8')) } catch {}
|
|
@@ -1065,9 +1213,11 @@ export async function runKeycloud (argv) {
|
|
|
1065
1213
|
if (sub === 'put') return cloudPut(args[0], { dry })
|
|
1066
1214
|
if (sub === 'get') return cloudGet(args[0], args[1])
|
|
1067
1215
|
if (sub === 'list') return cloudList()
|
|
1216
|
+
if (sub === 'catalog') return cloudCatalog({ dry })
|
|
1068
1217
|
console.log('usage:')
|
|
1069
1218
|
console.log(' keychat-save keycloud put <file> [--dry] one tx, sealed to your own key')
|
|
1070
1219
|
console.log(' keychat-save keycloud get <txid> <dest>')
|
|
1071
1220
|
console.log(' keychat-save keycloud list')
|
|
1221
|
+
console.log(' keychat-save keycloud catalog publish identifiers for existing on-chain items')
|
|
1072
1222
|
process.exitCode = 1
|
|
1073
1223
|
}
|