html-cloud 0.1.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 ADDED
@@ -0,0 +1,64 @@
1
+ # html-cloud
2
+
3
+ Share an HTML file privately from the command line — encrypted in your own
4
+ process before anything is uploaded. No account, no project setup, no public URL.
5
+
6
+ ```sh
7
+ npx html-cloud ./report.html
8
+ ```
9
+
10
+ ```
11
+ Share link (anyone with this can view):
12
+ https://html.cloud/v/kT4eN7xQ#b3FvXyJq…
13
+
14
+ Edit link (keep private — replace, change expiry, delete):
15
+ https://html.cloud/e/kT4eN7xQ#9dKw2mPv…
16
+ ```
17
+
18
+ Pipe straight from a generator:
19
+
20
+ ```sh
21
+ my-report-tool | npx html-cloud -
22
+ ```
23
+
24
+ ## Why this exists
25
+
26
+ AI tools (Claude, ChatGPT, Gemini) produce self-contained HTML — presentations,
27
+ reports, dashboards, prototypes. Sending one to a client or colleague usually
28
+ means a public deploy or a clunky attachment. `html-cloud` gives you a private
29
+ link in one command.
30
+
31
+ ## How the encryption works
32
+
33
+ - The file is encrypted with **AES-256-GCM** locally, in this process.
34
+ - The decryption key is placed after the `#` in the share link. URL fragments
35
+ are never sent to servers — by the browser or by this tool.
36
+ - The server stores **only ciphertext**. It cannot read your file; nobody can
37
+ without your link. This is the same zero-knowledge model as the
38
+ [html.cloud](https://html.cloud) website, using the same
39
+ [open-source crypto module](https://github.com/viljamilaurila/html-cloud).
40
+
41
+ Read the full explainer: [html.cloud/security](https://html.cloud/security)
42
+
43
+ ## Options
44
+
45
+ | Option | Description | Default |
46
+ |---|---|---|
47
+ | `--expires <7\|30\|never>` | Days until the link expires | `30` |
48
+ | `--url <base>` | Server base URL (or `$HTML_CLOUD_URL`) | `https://html.cloud` |
49
+ | `--no-copy` | Don't copy the share link to the clipboard | copy is on |
50
+
51
+ In interactive use the share link is copied to your clipboard (`pbcopy`,
52
+ `wl-copy`/`xclip`/`xsel`, or `clip`, whichever your OS has). When output is
53
+ piped or scripted, the clipboard is never touched.
54
+
55
+ Limits: one `.html`/`.htm` file (or stdin), max 10 MB. Expiry can be changed
56
+ later from the edit link.
57
+
58
+ ## Honest threat model
59
+
60
+ Anyone who has the share link can read the file — link handling is on you.
61
+ The server can delete or expire ciphertext but can never read it. For details
62
+ and limitations, see [html.cloud/security](https://html.cloud/security).
63
+
64
+ Requires Node 20+. MIT licensed.
@@ -0,0 +1,151 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * npx html-cloud ./file.html
4
+ *
5
+ * Encrypts an HTML file locally (AES-256-GCM) and uploads only the
6
+ * ciphertext to html.cloud. Keys are generated here and printed as URL
7
+ * fragments — they are never sent to the server.
8
+ */
9
+
10
+ import { spawnSync } from 'node:child_process';
11
+ import { readFileSync } from 'node:fs';
12
+ import { basename } from 'node:path';
13
+ import { parseArgs } from 'node:util';
14
+
15
+ import {
16
+ generateViewKey, generateEditKey, exportViewKey,
17
+ encryptBytes, encryptViewKeyWithEditKey, computeEditAuth,
18
+ packCiphertext, b64url,
19
+ } from '../crypto.js';
20
+
21
+ const MAX_SIZE = 10 * 1024 * 1024; // 10 MB, matches the server limit
22
+
23
+ const HELP = `
24
+ html-cloud — private HTML file sharing, encrypted before upload
25
+
26
+ Usage:
27
+ npx html-cloud <file.html> [options]
28
+ cat page.html | npx html-cloud -
29
+
30
+ Options:
31
+ --expires <7|30|never> Days until the link expires (default: 30)
32
+ --url <base> Server base URL (default: https://html.cloud,
33
+ or $HTML_CLOUD_URL)
34
+ --no-copy Don't copy the share link to the clipboard
35
+ -h, --help Show this help
36
+
37
+ The file is encrypted with AES-256-GCM in this process. The decryption
38
+ key is placed after the # in the link — browsers never send that part
39
+ to servers, and html.cloud stores only ciphertext.
40
+ `.trim();
41
+
42
+ function fail(msg) {
43
+ console.error(`error: ${msg}`);
44
+ process.exit(1);
45
+ }
46
+
47
+ /** Copy text via the OS clipboard command. Returns true on success. */
48
+ function copyToClipboard(text) {
49
+ const candidates = process.platform === 'darwin' ? [['pbcopy']]
50
+ : process.platform === 'win32' ? [['clip']]
51
+ : [['wl-copy'], ['xclip', '-selection', 'clipboard'], ['xsel', '--clipboard', '--input']];
52
+ for (const [cmd, ...cmdArgs] of candidates) {
53
+ const r = spawnSync(cmd, cmdArgs, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
54
+ if (!r.error && r.status === 0) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ let args;
60
+ try {
61
+ args = parseArgs({
62
+ allowPositionals: true,
63
+ options: {
64
+ expires: { type: 'string', default: '30' },
65
+ url: { type: 'string' },
66
+ 'no-copy': { type: 'boolean', default: false },
67
+ help: { type: 'boolean', short: 'h', default: false },
68
+ },
69
+ });
70
+ } catch (err) {
71
+ fail(err.message);
72
+ }
73
+
74
+ if (args.values.help || args.positionals.length === 0) {
75
+ console.log(HELP);
76
+ process.exit(args.values.help ? 0 : 1);
77
+ }
78
+
79
+ const input = args.positionals[0];
80
+ const expires = args.values.expires;
81
+ if (!['7', '30', 'never'].includes(expires)) {
82
+ fail(`--expires must be 7, 30 or never (got "${expires}")`);
83
+ }
84
+ const baseUrl = (args.values.url ?? process.env.HTML_CLOUD_URL ?? 'https://html.cloud')
85
+ .replace(/\/+$/, '');
86
+
87
+ let plaintext;
88
+ if (input === '-') {
89
+ plaintext = new Uint8Array(readFileSync(0));
90
+ } else {
91
+ if (!/\.html?$/i.test(input)) fail(`expected an .html or .htm file (got "${basename(input)}")`);
92
+ try {
93
+ plaintext = new Uint8Array(readFileSync(input));
94
+ } catch {
95
+ fail(`cannot read ${input}`);
96
+ }
97
+ }
98
+ if (plaintext.length === 0) fail('input is empty');
99
+ if (plaintext.length > MAX_SIZE) fail('file is too large (max 10 MB)');
100
+
101
+ // 1. Generate keys locally — these never leave this process except inside the printed links.
102
+ const viewKey = await generateViewKey();
103
+ const editKeyRaw = await generateEditKey();
104
+ const viewKeyRaw = await exportViewKey(viewKey);
105
+
106
+ // 2. Encrypt content and wrap the view key with the edit key.
107
+ const { iv, ciphertext } = await encryptBytes(viewKey, plaintext);
108
+ const packed = packCiphertext(iv, ciphertext);
109
+ const encryptedViewKey = await encryptViewKeyWithEditKey(viewKeyRaw, editKeyRaw);
110
+ const editAuth = await computeEditAuth(editKeyRaw);
111
+
112
+ // 3. Upload ciphertext only.
113
+ let res;
114
+ try {
115
+ res = await fetch(`${baseUrl}/api/documents`, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({
119
+ ciphertext: packed,
120
+ encrypted_view_key: encryptedViewKey,
121
+ edit_auth: editAuth,
122
+ expires_in: expires,
123
+ size: plaintext.length,
124
+ }),
125
+ });
126
+ } catch {
127
+ fail(`could not reach ${baseUrl}`);
128
+ }
129
+
130
+ if (!res.ok) {
131
+ if (res.status === 429) fail('too many uploads — please wait a few minutes and try again');
132
+ const err = await res.json().catch(() => ({}));
133
+ fail(err.error || err.message || `upload failed (HTTP ${res.status})`);
134
+ }
135
+
136
+ const { id } = await res.json();
137
+ const shareLink = `${baseUrl}/v/${id}#${b64url(viewKeyRaw)}`;
138
+
139
+ // Copy only in interactive use — never alter the clipboard from scripts/pipes.
140
+ const copied = !args.values['no-copy'] && process.stdout.isTTY && copyToClipboard(shareLink);
141
+
142
+ const expiryNote = expires === 'never' ? 'never expires' : `expires in ${expires} days`;
143
+ console.log(`
144
+ Share link (anyone with this can view)${copied ? ' — copied to clipboard' : ''}:
145
+ ${shareLink}
146
+
147
+ Edit link (keep private — replace, change expiry, delete):
148
+ ${baseUrl}/e/${id}#${b64url(editKeyRaw)}
149
+
150
+ Encrypted locally with AES-256-GCM · ${expiryNote} · the server never saw the keys
151
+ `.trim());
package/crypto.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Zero-knowledge crypto helpers using Web Crypto API.
3
+ *
4
+ * Key model:
5
+ * viewKey — AES-256-GCM CryptoKey used to encrypt/decrypt the HTML content.
6
+ * editKey — 32 random bytes. Encrypts the viewKey (stored server-side).
7
+ * SHA-256(editKey) is stored as editAuth for authorization.
8
+ *
9
+ * URL model:
10
+ * /v/{id}#{base64url(viewKey raw bytes)}
11
+ * /e/{id}#{base64url(editKey)}
12
+ *
13
+ * The server sees only:
14
+ * - Encrypted content blob
15
+ * - viewKey encrypted with editKey (so edit page can re-derive viewKey)
16
+ * - SHA-256(editKey) for authorization
17
+ */
18
+
19
+ const ENC = 'AES-GCM';
20
+ const KEY_LEN = 256;
21
+ const IV_LEN = 12; // bytes for AES-GCM nonce
22
+
23
+ export function b64url(bytes) {
24
+ // Chunked to avoid "maximum call stack" when spreading large arrays into String.fromCharCode
25
+ let str = '';
26
+ const CHUNK = 8192;
27
+ for (let i = 0; i < bytes.length; i += CHUNK) {
28
+ str += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
29
+ }
30
+ return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
31
+ }
32
+
33
+ export function b64urlDecode(str) {
34
+ str = str.replace(/-/g, '+').replace(/_/g, '/');
35
+ const pad = (4 - str.length % 4) % 4;
36
+ return Uint8Array.from(atob(str + '='.repeat(pad)), c => c.charCodeAt(0));
37
+ }
38
+
39
+ export async function generateViewKey() {
40
+ return crypto.subtle.generateKey({ name: ENC, length: KEY_LEN }, true, ['encrypt', 'decrypt']);
41
+ }
42
+
43
+ export async function generateEditKey() {
44
+ return crypto.getRandomValues(new Uint8Array(32));
45
+ }
46
+
47
+ export async function exportViewKey(key) {
48
+ const raw = await crypto.subtle.exportKey('raw', key);
49
+ return new Uint8Array(raw);
50
+ }
51
+
52
+ export async function importViewKey(rawBytes) {
53
+ return crypto.subtle.importKey('raw', rawBytes, { name: ENC }, false, ['encrypt', 'decrypt']);
54
+ }
55
+
56
+ /** Encrypt plaintext bytes → {iv, ciphertext} both as Uint8Array */
57
+ export async function encryptBytes(viewKey, plaintext) {
58
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
59
+ const buf = await crypto.subtle.encrypt({ name: ENC, iv }, viewKey, plaintext);
60
+ return { iv, ciphertext: new Uint8Array(buf) };
61
+ }
62
+
63
+ /** Decrypt → Uint8Array */
64
+ export async function decryptBytes(viewKey, iv, ciphertext) {
65
+ const buf = await crypto.subtle.decrypt({ name: ENC, iv }, viewKey, ciphertext);
66
+ return new Uint8Array(buf);
67
+ }
68
+
69
+ /**
70
+ * Encrypt the viewKey raw bytes using the editKey (raw bytes) as a password-derived AES key.
71
+ * Returns base64url string suitable for storing on the server.
72
+ */
73
+ export async function encryptViewKeyWithEditKey(viewKeyRaw, editKeyRaw) {
74
+ const wrapKey = await crypto.subtle.importKey(
75
+ 'raw', editKeyRaw, { name: ENC }, false, ['encrypt']
76
+ );
77
+ const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
78
+ const enc = await crypto.subtle.encrypt({ name: ENC, iv }, wrapKey, viewKeyRaw);
79
+ // Store as iv:ciphertext base64url
80
+ const combined = new Uint8Array(IV_LEN + enc.byteLength);
81
+ combined.set(iv);
82
+ combined.set(new Uint8Array(enc), IV_LEN);
83
+ return b64url(combined);
84
+ }
85
+
86
+ /**
87
+ * Decrypt the viewKey using the editKey.
88
+ * Returns raw viewKey bytes.
89
+ */
90
+ export async function decryptViewKeyWithEditKey(encryptedViewKeyB64, editKeyRaw) {
91
+ const combined = b64urlDecode(encryptedViewKeyB64);
92
+ const iv = combined.slice(0, IV_LEN);
93
+ const data = combined.slice(IV_LEN);
94
+ const wrapKey = await crypto.subtle.importKey(
95
+ 'raw', editKeyRaw, { name: ENC }, false, ['decrypt']
96
+ );
97
+ const raw = await crypto.subtle.decrypt({ name: ENC, iv }, wrapKey, data);
98
+ return new Uint8Array(raw);
99
+ }
100
+
101
+ /** SHA-256(editKey) → hex string for server-side authorization */
102
+ export async function computeEditAuth(editKeyRaw) {
103
+ const hash = await crypto.subtle.digest('SHA-256', editKeyRaw);
104
+ return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
105
+ }
106
+
107
+ /**
108
+ * Pack iv+ciphertext into a single base64url blob for wire transfer.
109
+ * Format: [12 bytes IV][N bytes ciphertext]
110
+ */
111
+ export function packCiphertext(iv, ciphertext) {
112
+ const out = new Uint8Array(IV_LEN + ciphertext.length);
113
+ out.set(iv);
114
+ out.set(ciphertext, IV_LEN);
115
+ return b64url(out);
116
+ }
117
+
118
+ export function unpackCiphertext(packed) {
119
+ const bytes = b64urlDecode(packed);
120
+ return { iv: bytes.slice(0, IV_LEN), ciphertext: bytes.slice(IV_LEN) };
121
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "html-cloud",
3
+ "version": "0.1.0",
4
+ "description": "Share an HTML file privately from the command line. Encrypted with AES-256-GCM before upload — the server stores only ciphertext. No account.",
5
+ "type": "module",
6
+ "bin": {
7
+ "html-cloud": "bin/html-cloud.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "crypto.js",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "keywords": [
18
+ "html",
19
+ "share",
20
+ "encrypted",
21
+ "zero-knowledge",
22
+ "private",
23
+ "claude-artifact",
24
+ "ai-generated"
25
+ ],
26
+ "homepage": "https://html.cloud",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/viljamilaurila/html-cloud.git",
30
+ "directory": "cli"
31
+ },
32
+ "license": "MIT"
33
+ }