powerbin 0.0.0-stage → 0.1.1
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/LICENSE +121 -0
- package/README.md +99 -2
- package/THIRD_PARTY_NOTICES.md +9579 -0
- package/dist/client.js +198 -0
- package/dist/crypto/powerbin_crypto.cjs +176 -0
- package/dist/crypto/powerbin_crypto.d.cts +8 -0
- package/dist/crypto/powerbin_crypto_bg.wasm +0 -0
- package/dist/crypto/powerbin_crypto_bg.wasm.d.ts +7 -0
- package/dist/index.js +105 -0
- package/package.json +47 -3
package/dist/client.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
3
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
4
|
+
export class CliError extends Error {
|
|
5
|
+
exitCode;
|
|
6
|
+
constructor(message, exitCode = 1) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.exitCode = exitCode;
|
|
9
|
+
this.name = 'CliError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function parseLink(input) {
|
|
13
|
+
// URL() normalizes whitespace and credentials; validate those before any I/O.
|
|
14
|
+
if (!input || input.length > 8192 || /[\u0000-\u0020\u007f]/u.test(input)) {
|
|
15
|
+
throw new CliError('Expected the complete original PowerBin link, including its #key.', 2);
|
|
16
|
+
}
|
|
17
|
+
let url;
|
|
18
|
+
try {
|
|
19
|
+
url = new URL(input);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new CliError('Invalid PowerBin URL.', 2);
|
|
23
|
+
}
|
|
24
|
+
const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
25
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) {
|
|
26
|
+
throw new CliError('PowerBin links must use HTTPS. HTTP is allowed only for local development.', 2);
|
|
27
|
+
}
|
|
28
|
+
if (url.username || url.password)
|
|
29
|
+
throw new CliError('PowerBin URLs cannot contain credentials.', 2);
|
|
30
|
+
if (url.pathname !== '/' || url.searchParams.size !== 1 || url.searchParams.getAll('link').length !== 1) {
|
|
31
|
+
throw new CliError('Expected a PowerBin link with the form https://host/?link=<id>#<key>.', 2);
|
|
32
|
+
}
|
|
33
|
+
const id = url.searchParams.get('link');
|
|
34
|
+
if (!UUID.test(id))
|
|
35
|
+
throw new CliError('The link contains an invalid message ID.', 2);
|
|
36
|
+
const secret = url.hash.slice(1);
|
|
37
|
+
if (!/^[A-Za-z0-9_-]{43}$/u.test(secret)) {
|
|
38
|
+
throw new CliError('The link is missing a valid decryption key. Paste the complete original link, including #key.', 2);
|
|
39
|
+
}
|
|
40
|
+
const key = Buffer.from(secret, 'base64url');
|
|
41
|
+
if (key.length !== 32 || key.toString('base64url') !== secret) {
|
|
42
|
+
key.fill(0);
|
|
43
|
+
throw new CliError('The link contains an invalid decryption key.', 2);
|
|
44
|
+
}
|
|
45
|
+
// UUIDs are stored and used as authenticated crypto context in canonical form.
|
|
46
|
+
const canonicalId = id.toLowerCase();
|
|
47
|
+
return { id: canonicalId, endpoint: `${url.origin}/api/links/${canonicalId}/reveal`, key };
|
|
48
|
+
}
|
|
49
|
+
function loadCrypto() {
|
|
50
|
+
try {
|
|
51
|
+
const crypto = createRequire(import.meta.url)('./crypto/powerbin_crypto.cjs');
|
|
52
|
+
if (typeof crypto.openGuest !== 'function')
|
|
53
|
+
throw new Error();
|
|
54
|
+
return crypto;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new CliError('The bundled decryption module could not load. Reinstall this package before revealing the message.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function statusError(status) {
|
|
61
|
+
switch (status) {
|
|
62
|
+
case 400: return new CliError('The server rejected this link. Check that you pasted the complete original URL.');
|
|
63
|
+
case 401:
|
|
64
|
+
case 403: return new CliError('This service denied access to the message.');
|
|
65
|
+
case 404: return new CliError('This link is unavailable or does not exist.');
|
|
66
|
+
case 410: return new CliError('This link has already been consumed or has expired.');
|
|
67
|
+
case 429: return new CliError('This service is receiving too many reveal requests. Try again shortly.');
|
|
68
|
+
default: return new CliError('The server could not complete the reveal. The message may already have burned; do not automatically retry.');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function responseBody(response) {
|
|
72
|
+
const chunks = [];
|
|
73
|
+
let length = 0;
|
|
74
|
+
const reader = response.body?.getReader();
|
|
75
|
+
if (!reader)
|
|
76
|
+
throw new Error();
|
|
77
|
+
try {
|
|
78
|
+
for (;;) {
|
|
79
|
+
const { value, done } = await reader.read();
|
|
80
|
+
if (done)
|
|
81
|
+
break;
|
|
82
|
+
length += value.byteLength;
|
|
83
|
+
if (length > MAX_RESPONSE_BYTES)
|
|
84
|
+
throw new Error();
|
|
85
|
+
chunks.push(value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await reader.cancel().catch(() => { });
|
|
90
|
+
reader.releaseLock();
|
|
91
|
+
}
|
|
92
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
|
|
93
|
+
}
|
|
94
|
+
async function checkProtocol(link, timeoutMs) {
|
|
95
|
+
let response;
|
|
96
|
+
try {
|
|
97
|
+
response = await fetch(link.endpoint.slice(0, -'/reveal'.length), {
|
|
98
|
+
method: 'GET',
|
|
99
|
+
headers: { Accept: 'application/json' },
|
|
100
|
+
redirect: 'manual',
|
|
101
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
throw new CliError('The link availability check was lost or timed out. This request did not consume the message.');
|
|
106
|
+
}
|
|
107
|
+
if (response.status >= 300 && response.status < 400) {
|
|
108
|
+
await response.body?.cancel().catch(() => { });
|
|
109
|
+
throw new CliError('The service redirected the availability check. No redirect was followed. This request did not consume the message.');
|
|
110
|
+
}
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
await response.body?.cancel().catch(() => { });
|
|
113
|
+
const reason = [400, 401, 403, 404, 410, 429].includes(response.status)
|
|
114
|
+
? statusError(response.status).message
|
|
115
|
+
: 'The server could not check this link.';
|
|
116
|
+
throw new CliError(`${reason} This request did not consume the message.`);
|
|
117
|
+
}
|
|
118
|
+
let metadata;
|
|
119
|
+
try {
|
|
120
|
+
metadata = JSON.parse(await responseBody(response));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
throw new CliError('The link metadata response was incomplete or invalid. This request did not consume the message.');
|
|
124
|
+
}
|
|
125
|
+
if (!metadata || typeof metadata !== 'object' || !('protocol_version' in metadata) || metadata.protocol_version !== 2) {
|
|
126
|
+
throw new CliError('This link/server uses an unsupported encryption format. Update PowerBin server/browser, then create a new link. This request did not consume the message.');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Check compatibility without consuming, then make a single consuming request. */
|
|
130
|
+
export async function revealLink(input, options = {}) {
|
|
131
|
+
const link = parseLink(input);
|
|
132
|
+
try {
|
|
133
|
+
const crypto = loadCrypto();
|
|
134
|
+
await checkProtocol(link, options.timeoutMs ?? 15_000);
|
|
135
|
+
let response;
|
|
136
|
+
try {
|
|
137
|
+
response = await fetch(link.endpoint, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
140
|
+
body: '{}',
|
|
141
|
+
redirect: 'manual',
|
|
142
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 15_000)
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new CliError('The reveal response was lost or timed out. The message may already have burned; do not automatically retry.');
|
|
147
|
+
}
|
|
148
|
+
if (response.status >= 300 && response.status < 400) {
|
|
149
|
+
await response.body?.cancel().catch(() => { });
|
|
150
|
+
throw new CliError('The service redirected the reveal request. No redirect was followed. The message may already have burned.');
|
|
151
|
+
}
|
|
152
|
+
if (!response.ok) {
|
|
153
|
+
await response.body?.cancel().catch(() => { });
|
|
154
|
+
throw statusError(response.status);
|
|
155
|
+
}
|
|
156
|
+
let payload;
|
|
157
|
+
try {
|
|
158
|
+
const body = JSON.parse(await responseBody(response));
|
|
159
|
+
if (!body || typeof body !== 'object' || !('payload' in body) || typeof body.payload !== 'string'
|
|
160
|
+
|| !/^[A-Za-z0-9_-]+$/u.test(body.payload))
|
|
161
|
+
throw new Error();
|
|
162
|
+
const bytes = Buffer.from(body.payload, 'base64url');
|
|
163
|
+
if (bytes.length < 29 || bytes.length > 32 * 1024 + 29 || bytes.toString('base64url') !== body.payload)
|
|
164
|
+
throw new Error();
|
|
165
|
+
payload = bytes;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
throw new CliError('The message was claimed, but its encrypted response was incomplete or invalid. It cannot be revealed again.');
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const plaintext = crypto.openGuest(link.key, link.id, payload);
|
|
172
|
+
try {
|
|
173
|
+
new TextDecoder('utf-8', { fatal: true }).decode(plaintext);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
plaintext.fill(0);
|
|
177
|
+
throw new Error();
|
|
178
|
+
}
|
|
179
|
+
return plaintext;
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
throw new CliError('The message was claimed, but decryption failed. Check the original link; the message cannot be revealed again.');
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
payload.fill(0);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
link.key.fill(0);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Escape controls only in human terminal output; raw and piped bytes stay exact. */
|
|
193
|
+
export function terminalText(text) {
|
|
194
|
+
return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, character => {
|
|
195
|
+
const code = character.charCodeAt(0);
|
|
196
|
+
return code < 256 ? `\\x${code.toString(16).padStart(2, '0')}` : `\\u${code.toString(16).padStart(4, '0')}`;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/* @ts-self-types="./powerbin_crypto.d.cts" */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decrypt a guest payload with the same protocol as PowerBin's native and web apps.
|
|
5
|
+
* The caller owns the lifetime of the JavaScript input and returned buffers.
|
|
6
|
+
* @param {Uint8Array} secret
|
|
7
|
+
* @param {string} item_id
|
|
8
|
+
* @param {Uint8Array} ciphertext
|
|
9
|
+
* @returns {Uint8Array}
|
|
10
|
+
*/
|
|
11
|
+
function openGuest(secret, item_id, ciphertext) {
|
|
12
|
+
try {
|
|
13
|
+
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
|
14
|
+
const ptr0 = passArray8ToWasm0(secret, wasm.__wbindgen_export);
|
|
15
|
+
const len0 = WASM_VECTOR_LEN;
|
|
16
|
+
const ptr1 = passStringToWasm0(item_id, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
|
17
|
+
const len1 = WASM_VECTOR_LEN;
|
|
18
|
+
const ptr2 = passArray8ToWasm0(ciphertext, wasm.__wbindgen_export);
|
|
19
|
+
const len2 = WASM_VECTOR_LEN;
|
|
20
|
+
wasm.openGuest(retptr, ptr0, len0, ptr1, len1, ptr2, len2);
|
|
21
|
+
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
|
22
|
+
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
|
23
|
+
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
|
24
|
+
if (r2) {
|
|
25
|
+
throw takeObject(r1);
|
|
26
|
+
}
|
|
27
|
+
return takeObject(r0);
|
|
28
|
+
} finally {
|
|
29
|
+
wasm.__wbindgen_add_to_stack_pointer(16);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.openGuest = openGuest;
|
|
33
|
+
function __wbg_get_imports() {
|
|
34
|
+
const import0 = {
|
|
35
|
+
__proto__: null,
|
|
36
|
+
__wbg_Error_30c8987f7c2ed4e2: function(arg0, arg1) {
|
|
37
|
+
const ret = Error(getStringFromWasm0(arg0, arg1));
|
|
38
|
+
return addHeapObject(ret);
|
|
39
|
+
},
|
|
40
|
+
__wbg___wbindgen_throw_41e9ee4f547fc59a: function(arg0, arg1) {
|
|
41
|
+
throw new Error(getStringFromWasm0(arg0, arg1));
|
|
42
|
+
},
|
|
43
|
+
__wbg_new_from_slice_9a868026ffa4208a: function(arg0, arg1) {
|
|
44
|
+
const ret = new Uint8Array(getArrayU8FromWasm0(arg0, arg1));
|
|
45
|
+
return addHeapObject(ret);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
__proto__: null,
|
|
50
|
+
"./powerbin_crypto_bg.js": import0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function addHeapObject(obj) {
|
|
55
|
+
if (heap_next === heap.length) heap.push(heap.length + 1);
|
|
56
|
+
const idx = heap_next;
|
|
57
|
+
heap_next = heap[idx];
|
|
58
|
+
|
|
59
|
+
heap[idx] = obj;
|
|
60
|
+
return idx;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function dropObject(idx) {
|
|
64
|
+
if (idx < 1028) return;
|
|
65
|
+
heap[idx] = heap_next;
|
|
66
|
+
heap_next = idx;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getArrayU8FromWasm0(ptr, len) {
|
|
70
|
+
ptr = ptr >>> 0;
|
|
71
|
+
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let cachedDataViewMemory0 = null;
|
|
75
|
+
function getDataViewMemory0() {
|
|
76
|
+
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
|
77
|
+
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
|
78
|
+
}
|
|
79
|
+
return cachedDataViewMemory0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getStringFromWasm0(ptr, len) {
|
|
83
|
+
return decodeText(ptr >>> 0, len);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let cachedUint8ArrayMemory0 = null;
|
|
87
|
+
function getUint8ArrayMemory0() {
|
|
88
|
+
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
|
89
|
+
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
|
90
|
+
}
|
|
91
|
+
return cachedUint8ArrayMemory0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getObject(idx) { return heap[idx]; }
|
|
95
|
+
|
|
96
|
+
let heap = new Array(1024).fill(undefined);
|
|
97
|
+
heap.push(undefined, null, true, false);
|
|
98
|
+
|
|
99
|
+
let heap_next = heap.length;
|
|
100
|
+
|
|
101
|
+
function passArray8ToWasm0(arg, malloc) {
|
|
102
|
+
const ptr = malloc(arg.length * 1, 1) >>> 0;
|
|
103
|
+
getUint8ArrayMemory0().set(arg, ptr / 1);
|
|
104
|
+
WASM_VECTOR_LEN = arg.length;
|
|
105
|
+
return ptr;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function passStringToWasm0(arg, malloc, realloc) {
|
|
109
|
+
if (realloc === undefined) {
|
|
110
|
+
const buf = cachedTextEncoder.encode(arg);
|
|
111
|
+
const ptr = malloc(buf.length, 1) >>> 0;
|
|
112
|
+
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
|
|
113
|
+
WASM_VECTOR_LEN = buf.length;
|
|
114
|
+
return ptr;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let len = arg.length;
|
|
118
|
+
let ptr = malloc(len, 1) >>> 0;
|
|
119
|
+
|
|
120
|
+
const mem = getUint8ArrayMemory0();
|
|
121
|
+
|
|
122
|
+
let offset = 0;
|
|
123
|
+
|
|
124
|
+
for (; offset < len; offset++) {
|
|
125
|
+
const code = arg.charCodeAt(offset);
|
|
126
|
+
if (code > 0x7F) break;
|
|
127
|
+
mem[ptr + offset] = code;
|
|
128
|
+
}
|
|
129
|
+
if (offset !== len) {
|
|
130
|
+
if (offset !== 0) {
|
|
131
|
+
arg = arg.slice(offset);
|
|
132
|
+
}
|
|
133
|
+
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
|
134
|
+
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
|
135
|
+
const ret = cachedTextEncoder.encodeInto(arg, view);
|
|
136
|
+
|
|
137
|
+
offset += ret.written;
|
|
138
|
+
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
WASM_VECTOR_LEN = offset;
|
|
142
|
+
return ptr;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function takeObject(idx) {
|
|
146
|
+
const ret = getObject(idx);
|
|
147
|
+
dropObject(idx);
|
|
148
|
+
return ret;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
|
152
|
+
cachedTextDecoder.decode();
|
|
153
|
+
function decodeText(ptr, len) {
|
|
154
|
+
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const cachedTextEncoder = new TextEncoder();
|
|
158
|
+
|
|
159
|
+
if (!('encodeInto' in cachedTextEncoder)) {
|
|
160
|
+
cachedTextEncoder.encodeInto = function (arg, view) {
|
|
161
|
+
const buf = cachedTextEncoder.encode(arg);
|
|
162
|
+
view.set(buf);
|
|
163
|
+
return {
|
|
164
|
+
read: arg.length,
|
|
165
|
+
written: buf.length
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let WASM_VECTOR_LEN = 0;
|
|
171
|
+
|
|
172
|
+
const wasmPath = `${__dirname}/powerbin_crypto_bg.wasm`;
|
|
173
|
+
const wasmBytes = require('fs').readFileSync(wasmPath);
|
|
174
|
+
const wasmModule = new WebAssembly.Module(wasmBytes);
|
|
175
|
+
let wasmInstance = new WebAssembly.Instance(wasmModule, __wbg_get_imports());
|
|
176
|
+
let wasm = wasmInstance.exports;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* tslint:disable */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Decrypt a guest payload with the same protocol as PowerBin's native and web apps.
|
|
6
|
+
* The caller owns the lifetime of the JavaScript input and returned buffers.
|
|
7
|
+
*/
|
|
8
|
+
export function openGuest(secret: Uint8Array, item_id: string, ciphertext: Uint8Array): Uint8Array;
|
|
Binary file
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/* tslint:disable */
|
|
2
|
+
/* eslint-disable */
|
|
3
|
+
export const memory: WebAssembly.Memory;
|
|
4
|
+
export const openGuest: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => void;
|
|
5
|
+
export const __wbindgen_add_to_stack_pointer: (a: number) => number;
|
|
6
|
+
export const __wbindgen_export: (a: number, b: number) => number;
|
|
7
|
+
export const __wbindgen_export2: (a: number, b: number, c: number, d: number) => number;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { CliError, revealLink, terminalText } from './client.js';
|
|
4
|
+
const help = `PowerBin — reveal a one-time message locally
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
powerbin reveal '<complete link, including #key>' [--raw]
|
|
8
|
+
powerbin reveal --stdin [--raw]
|
|
9
|
+
|
|
10
|
+
Commands:
|
|
11
|
+
reveal Claim, decrypt, and burn a one-time message
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--stdin Read the original link from standard input
|
|
15
|
+
--raw Write the exact message bytes without terminal formatting
|
|
16
|
+
-h, --help Show this help
|
|
17
|
+
-v, --version Show the package version
|
|
18
|
+
|
|
19
|
+
The decryption key stays on this computer. Revealing consumes the link once.
|
|
20
|
+
Piped output contains only the exact message bytes; status goes to stderr.
|
|
21
|
+
`;
|
|
22
|
+
function argumentsForReveal(args) {
|
|
23
|
+
const result = { stdin: false, raw: false };
|
|
24
|
+
for (const argument of args) {
|
|
25
|
+
if (argument === '--raw' && !result.raw)
|
|
26
|
+
result.raw = true;
|
|
27
|
+
else if (argument === '--stdin' && !result.stdin)
|
|
28
|
+
result.stdin = true;
|
|
29
|
+
else if (argument.startsWith('-'))
|
|
30
|
+
throw new CliError('Unknown or repeated option. Run powerbin --help for usage.', 2);
|
|
31
|
+
else if (result.url === undefined)
|
|
32
|
+
result.url = argument;
|
|
33
|
+
else
|
|
34
|
+
throw new CliError('Reveal accepts one link. Run powerbin --help for usage.', 2);
|
|
35
|
+
}
|
|
36
|
+
if (result.stdin === (result.url !== undefined)) {
|
|
37
|
+
throw new CliError('Provide one complete link, or use --stdin to read it from standard input.', 2);
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
async function stdinLink() {
|
|
42
|
+
const chunks = [];
|
|
43
|
+
let size = 0;
|
|
44
|
+
for await (const chunk of process.stdin) {
|
|
45
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
46
|
+
size += bytes.length;
|
|
47
|
+
if (size > 8192)
|
|
48
|
+
throw new CliError('Standard input must contain a single PowerBin link.', 2);
|
|
49
|
+
chunks.push(bytes);
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)).trim();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new CliError('Standard input must contain a valid UTF-8 PowerBin link.', 2);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function writeMessage(bytes) {
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
process.stdout.write(bytes, error => error ? reject(error) : resolve());
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
async function main() {
|
|
64
|
+
const args = process.argv.slice(2);
|
|
65
|
+
if (args.length === 0 || (args.length === 1 && ['--help', '-h'].includes(args[0]))
|
|
66
|
+
|| (args[0] === 'reveal' && args.length === 2 && ['--help', '-h'].includes(args[1]))) {
|
|
67
|
+
process.stdout.write(help);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (args.length === 1 && ['--version', '-v'].includes(args[0])) {
|
|
71
|
+
const packageInfo = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
72
|
+
process.stdout.write(`${packageInfo.version}\n`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (args[0] !== 'reveal')
|
|
76
|
+
throw new CliError('Unknown command. Run powerbin --help for usage.', 2);
|
|
77
|
+
const options = argumentsForReveal(args.slice(1));
|
|
78
|
+
const url = options.stdin ? await stdinLink() : options.url;
|
|
79
|
+
const plaintext = await revealLink(url);
|
|
80
|
+
try {
|
|
81
|
+
if (process.stdout.isTTY && !options.raw) {
|
|
82
|
+
process.stderr.write('PowerBin · one-time message\n\n');
|
|
83
|
+
const visible = terminalText(new TextDecoder().decode(plaintext));
|
|
84
|
+
await writeMessage(Buffer.from(visible + (visible.endsWith('\n') ? '' : '\n')));
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
await writeMessage(plaintext);
|
|
88
|
+
}
|
|
89
|
+
if (!options.raw)
|
|
90
|
+
process.stderr.write('Revealed. The server copy has burned.\n');
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new CliError('The message was revealed, but writing it to standard output failed. The link has already burned.');
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
plaintext.fill(0);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Stream write failures are reported through their callbacks instead of a stack trace.
|
|
100
|
+
process.stdout.on('error', () => { });
|
|
101
|
+
process.stderr.on('error', () => { });
|
|
102
|
+
main().catch(error => {
|
|
103
|
+
process.stderr.write(`PowerBin: ${error instanceof CliError ? error.message : 'The command could not complete.'}\n`);
|
|
104
|
+
process.exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
105
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "powerbin",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Reveal and burn PowerBin one-time messages from your terminal, with local decryption.",
|
|
5
|
+
"license": "CC0-1.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"powerbin": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/**/*.js",
|
|
12
|
+
"dist/**/*.cjs",
|
|
13
|
+
"dist/**/*.wasm",
|
|
14
|
+
"dist/**/*.d.ts",
|
|
15
|
+
"dist/**/*.d.cts",
|
|
16
|
+
"THIRD_PARTY_NOTICES.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=22.12.0"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/kobiebotha/powersync-native-shell-demo.git",
|
|
25
|
+
"directory": "cli"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"powerbin",
|
|
29
|
+
"cli",
|
|
30
|
+
"one-time",
|
|
31
|
+
"encrypted",
|
|
32
|
+
"secret-sharing"
|
|
33
|
+
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"registry": "https://registry.npmjs.org/"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "24.19.2",
|
|
40
|
+
"typescript": "7.0.2"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "pnpm run build:crypto && pnpm run build:notices && pnpm run build:ts",
|
|
44
|
+
"build:crypto": "node scripts/build-crypto.mjs",
|
|
45
|
+
"build:notices": "node scripts/build-notices.mjs",
|
|
46
|
+
"build:ts": "tsc -p tsconfig.json",
|
|
47
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
48
|
+
"test": "node --test test/*.test.mjs"
|
|
49
|
+
}
|
|
6
50
|
}
|