feat: rebrand Hemmelig to paste.es for cloudhost.es
- Set Spanish as default language with ephemeral/encrypted privacy focus - Translate all user-facing strings and legal pages to Spanish - Replace Norwegian flag with Spanish flag in footer - Remove Hemmelig/terces.cloud links, add cloudhost.es sponsorship - Rewrite PrivacyPage: zero data collection, ephemeral design emphasis - Rewrite TermsPage: Spanish law, RGPD, paste.es/CloudHost.es references - Update PWA manifest, HTML meta tags, package.json branding - Rename webhook headers to X-Paste-Event / X-Paste-Signature - Update API docs title and contact to paste.es / cloudhost.es Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
197
cli/src/bin.ts
Normal file
197
cli/src/bin.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createSecret, EXPIRATION_TIMES, type ExpirationKey, type SecretOptions } from './index.js';
|
||||
|
||||
const VERSION = '1.0.0';
|
||||
|
||||
/**
|
||||
* Prints help message
|
||||
*/
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
_ _ _ _
|
||||
| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _
|
||||
| |_| |/ _ \\ '_ \` _ \\| '_ \` _ \\ / _ \\ | |/ _\` |
|
||||
| _ | __/ | | | | | | | | | | __/ | | (_| |
|
||||
|_| |_|\\___|_| |_| |_|_| |_| |_|\\___|_|_|\\__, |
|
||||
|___/
|
||||
Create encrypted secrets from the command line
|
||||
|
||||
Usage:
|
||||
hemmelig <secret> [options]
|
||||
echo "secret" | hemmelig [options]
|
||||
hemmelig --help
|
||||
|
||||
Options:
|
||||
-t, --title <title> Set a title for the secret
|
||||
-p, --password <pass> Protect with a password (if not set, key is in URL)
|
||||
-e, --expires <time> Expiration time (default: 1d)
|
||||
Valid: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d
|
||||
-v, --views <number> Max views before deletion (default: 1, max: 9999)
|
||||
-b, --burnable Burn after first view (default: true)
|
||||
--no-burnable Don't burn after first view
|
||||
-u, --url <url> Base URL (default: https://hemmelig.app)
|
||||
-h, --help Show this help message
|
||||
--version Show version number
|
||||
|
||||
Examples:
|
||||
# Create a simple secret
|
||||
hemmelig "my secret message"
|
||||
|
||||
# Create a secret with a title and 7-day expiration
|
||||
hemmelig "my secret" -t "API Key" -e 7d
|
||||
|
||||
# Create a password-protected secret
|
||||
hemmelig "my secret" -p "mypassword123"
|
||||
|
||||
# Create a secret with 5 views allowed
|
||||
hemmelig "my secret" -v 5
|
||||
|
||||
# Pipe content from a file
|
||||
cat ~/.ssh/id_rsa.pub | hemmelig -t "SSH Public Key"
|
||||
|
||||
# Use a self-hosted instance
|
||||
hemmelig "my secret" -u https://secrets.mycompany.com
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses command line arguments
|
||||
*/
|
||||
function parseArgs(args: string[]): SecretOptions & { help?: boolean; version?: boolean } {
|
||||
const options: SecretOptions & { help?: boolean; version?: boolean } = {
|
||||
secret: '',
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < args.length) {
|
||||
const arg = args[i];
|
||||
|
||||
switch (arg) {
|
||||
case '-h':
|
||||
case '--help':
|
||||
options.help = true;
|
||||
break;
|
||||
case '--version':
|
||||
options.version = true;
|
||||
break;
|
||||
case '-t':
|
||||
case '--title':
|
||||
options.title = args[++i];
|
||||
break;
|
||||
case '-p':
|
||||
case '--password':
|
||||
options.password = args[++i];
|
||||
break;
|
||||
case '-e':
|
||||
case '--expires':
|
||||
options.expiresIn = args[++i] as ExpirationKey;
|
||||
break;
|
||||
case '-v':
|
||||
case '--views':
|
||||
options.views = parseInt(args[++i], 10);
|
||||
break;
|
||||
case '-b':
|
||||
case '--burnable':
|
||||
options.burnable = true;
|
||||
break;
|
||||
case '--no-burnable':
|
||||
options.burnable = false;
|
||||
break;
|
||||
case '-u':
|
||||
case '--url':
|
||||
options.baseUrl = args[++i];
|
||||
break;
|
||||
default:
|
||||
// If it doesn't start with -, it's the secret
|
||||
if (!arg.startsWith('-') && !options.secret) {
|
||||
options.secret = arg;
|
||||
}
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads from stdin if available
|
||||
*/
|
||||
async function readStdin(): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
// Check if stdin is a TTY (interactive terminal)
|
||||
if (process.stdin.isTTY) {
|
||||
resolve('');
|
||||
return;
|
||||
}
|
||||
|
||||
let data = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
process.stdin.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
process.stdin.on('end', () => {
|
||||
// Only trim trailing whitespace to preserve internal formatting
|
||||
resolve(data.trimEnd());
|
||||
});
|
||||
|
||||
// Timeout after 100ms if no data
|
||||
setTimeout(() => {
|
||||
if (!data) {
|
||||
resolve('');
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const options = parseArgs(args);
|
||||
|
||||
if (options.version) {
|
||||
console.log(VERSION);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Try to read from stdin if no secret provided
|
||||
if (!options.secret) {
|
||||
options.secret = await readStdin();
|
||||
}
|
||||
|
||||
if (!options.secret) {
|
||||
console.error('Error: No secret provided. Use --help for usage information.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate expiration time
|
||||
if (options.expiresIn && !(options.expiresIn in EXPIRATION_TIMES)) {
|
||||
console.error(`Error: Invalid expiration time "${options.expiresIn}".`);
|
||||
console.error('Valid options: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate views
|
||||
if (options.views !== undefined && (options.views < 1 || options.views > 9999)) {
|
||||
console.error('Error: Views must be between 1 and 9999.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createSecret(options);
|
||||
console.log(result.url);
|
||||
} catch (error) {
|
||||
console.error(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
210
cli/src/index.ts
Normal file
210
cli/src/index.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { createCipheriv, pbkdf2Sync, randomBytes } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Valid expiration times in seconds
|
||||
*/
|
||||
export const EXPIRATION_TIMES = {
|
||||
'5m': 300,
|
||||
'30m': 1800,
|
||||
'1h': 3600,
|
||||
'4h': 14400,
|
||||
'12h': 43200,
|
||||
'1d': 86400,
|
||||
'3d': 259200,
|
||||
'7d': 604800,
|
||||
'14d': 1209600,
|
||||
'28d': 2419200,
|
||||
} as const;
|
||||
|
||||
export type ExpirationKey = keyof typeof EXPIRATION_TIMES;
|
||||
|
||||
/**
|
||||
* Options for creating a secret
|
||||
*/
|
||||
export interface SecretOptions {
|
||||
/** The secret content to encrypt */
|
||||
secret: string;
|
||||
/** Optional title for the secret */
|
||||
title?: string;
|
||||
/** Optional password protection */
|
||||
password?: string;
|
||||
/** Expiration time (default: '1d') */
|
||||
expiresIn?: ExpirationKey;
|
||||
/** Maximum number of views (default: 1, max: 9999) */
|
||||
views?: number;
|
||||
/** Whether to burn after first view (default: true) */
|
||||
burnable?: boolean;
|
||||
/** Base URL of the Hemmelig instance (default: 'https://hemmelig.app') */
|
||||
baseUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from creating a secret
|
||||
*/
|
||||
export interface CreateSecretResult {
|
||||
/** The full URL to access the secret */
|
||||
url: string;
|
||||
/** The secret ID */
|
||||
id: string;
|
||||
/** The expiration time that was set */
|
||||
expiresIn: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random 32-character string using URL-safe base64 encoding
|
||||
*/
|
||||
function generateKey(): string {
|
||||
return randomBytes(24).toString('base64url').slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random 32-character salt
|
||||
*/
|
||||
function generateSalt(): string {
|
||||
return randomBytes(24).toString('base64url').slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a 256-bit AES key using PBKDF2-SHA256
|
||||
*/
|
||||
function deriveKey(password: string, salt: string): Buffer {
|
||||
return pbkdf2Sync(password, salt, 1300000, 32, 'sha256');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts data using AES-256-GCM
|
||||
* Returns IV (12 bytes) + ciphertext + auth tag (16 bytes)
|
||||
*/
|
||||
function encrypt(data: Buffer, encryptionKey: string, salt: string): Uint8Array {
|
||||
const key = deriveKey(encryptionKey, salt);
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Format: IV (12 bytes) + ciphertext + authTag (16 bytes)
|
||||
const fullMessage = new Uint8Array(iv.length + encrypted.length + authTag.length);
|
||||
fullMessage.set(iv, 0);
|
||||
fullMessage.set(encrypted, iv.length);
|
||||
fullMessage.set(authTag, iv.length + encrypted.length);
|
||||
|
||||
return fullMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts text using AES-256-GCM
|
||||
*/
|
||||
function encryptText(text: string, encryptionKey: string, salt: string): Uint8Array {
|
||||
return encrypt(Buffer.from(text, 'utf8'), encryptionKey, salt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts Uint8Array to a JSON-serializable object format
|
||||
* This matches the format expected by the API's jsonToUint8ArraySchema
|
||||
*/
|
||||
function uint8ArrayToObject(arr: Uint8Array): Record<string, number> {
|
||||
const obj: Record<string, number> = {};
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
obj[i.toString()] = arr[i];
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an encrypted secret on a Hemmelig server
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createSecret } from 'hemmelig';
|
||||
*
|
||||
* const result = await createSecret({
|
||||
* secret: 'my secret message',
|
||||
* title: 'API Key',
|
||||
* expiresIn: '1h',
|
||||
* views: 1
|
||||
* });
|
||||
*
|
||||
* console.log(result.url); // https://hemmelig.app/secret/abc123#decryptionKey=...
|
||||
* ```
|
||||
*/
|
||||
export async function createSecret(options: SecretOptions): Promise<CreateSecretResult> {
|
||||
const {
|
||||
secret,
|
||||
title,
|
||||
password,
|
||||
expiresIn = '1d',
|
||||
views = 1,
|
||||
burnable = true,
|
||||
baseUrl = 'https://hemmelig.app',
|
||||
} = options;
|
||||
|
||||
// Validate expiration time
|
||||
if (!(expiresIn in EXPIRATION_TIMES)) {
|
||||
throw new Error(
|
||||
`Invalid expiration time "${expiresIn}". Valid options: ${Object.keys(EXPIRATION_TIMES).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate views
|
||||
if (views < 1 || views > 9999) {
|
||||
throw new Error('Views must be between 1 and 9999');
|
||||
}
|
||||
|
||||
// Generate encryption key and salt
|
||||
const encryptionKey = password || generateKey();
|
||||
const salt = generateSalt();
|
||||
|
||||
// Encrypt the secret (and title if provided)
|
||||
const encryptedSecret = encryptText(secret, encryptionKey, salt);
|
||||
const encryptedTitle = title ? encryptText(title, encryptionKey, salt) : null;
|
||||
|
||||
// Prepare the request payload
|
||||
const payload: Record<string, unknown> = {
|
||||
secret: uint8ArrayToObject(encryptedSecret),
|
||||
salt,
|
||||
expiresAt: EXPIRATION_TIMES[expiresIn],
|
||||
views,
|
||||
isBurnable: burnable,
|
||||
};
|
||||
|
||||
if (encryptedTitle) {
|
||||
payload.title = uint8ArrayToObject(encryptedTitle);
|
||||
}
|
||||
|
||||
// If password is provided, send it for server-side hashing
|
||||
// Otherwise, leave it empty (key will be in URL fragment)
|
||||
if (password) {
|
||||
payload.password = password;
|
||||
}
|
||||
|
||||
// Make the API request
|
||||
const response = await fetch(`${baseUrl}/api/secrets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json().catch(() => ({ error: 'Unknown error' }))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(`Failed to create secret: ${errorData.error || response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { id: string };
|
||||
|
||||
// Construct the URL
|
||||
// If no password was provided, include the decryption key in the URL fragment
|
||||
const url = password
|
||||
? `${baseUrl}/secret/${data.id}`
|
||||
: `${baseUrl}/secret/${data.id}#decryptionKey=${encryptionKey}`;
|
||||
|
||||
return {
|
||||
url,
|
||||
id: data.id,
|
||||
expiresIn,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user