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:
2026-02-24 09:30:19 +01:00
commit bc9f96cbd4
268 changed files with 45773 additions and 0 deletions

146
cli/README.md Normal file
View File

@@ -0,0 +1,146 @@
# hemmelig
CLI and library for creating encrypted, self-destructing secrets via [Hemmelig](https://hemmelig.app).
```
_ _ _ _
| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _
| |_| |/ _ \ '_ ` _ \| '_ ` _ \ / _ \ | |/ _` |
| _ | __/ | | | | | | | | | | __/ | | (_| |
|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, |
|___/
```
## Features
- **Client-side AES-256-GCM encryption** - Your secrets are encrypted before leaving your machine
- **Zero-knowledge** - The server never sees your plaintext secrets
- **Self-destructing** - Secrets auto-delete after views or expiration
- **Password protection** - Optional additional security layer
- **Works with any Hemmelig instance** - Use hemmelig.app or self-hosted
## Installation
```bash
npm install -g hemmelig
```
Or use with npx:
```bash
npx hemmelig "my secret"
```
## CLI Usage
```bash
# Create a simple secret
hemmelig "my secret message"
# With a title and custom expiration
hemmelig "API key: sk-1234" -t "Production API Key" -e 7d
# Password protected
hemmelig "sensitive data" -p "mypassword"
# Multiple views allowed
hemmelig "shared config" -v 5
# Pipe from stdin
cat config.json | hemmelig -t "Config file"
echo "my secret" | hemmelig
# Use a self-hosted instance
hemmelig "internal secret" -u https://secrets.company.com
```
### Options
| Option | Description |
| ----------------------- | ------------------------------------------------------ |
| `-t, --title <title>` | Set a title for the secret |
| `-p, --password <pass>` | Protect with a password |
| `-e, --expires <time>` | Expiration: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d |
| `-v, --views <number>` | Max views (1-9999, default: 1) |
| `-b, --burnable` | Burn after first view (default) |
| `--no-burnable` | Don't burn until all views used |
| `-u, --url <url>` | Base URL (default: https://hemmelig.app) |
| `-h, --help` | Show help |
| `--version` | Show version |
## Library Usage
```typescript
import { createSecret } from 'hemmelig';
const result = await createSecret({
secret: 'my secret message',
title: 'API Key',
expiresIn: '1h',
views: 1,
burnable: true,
baseUrl: 'https://hemmelig.app', // optional
});
console.log(result.url); // https://hemmelig.app/secret/abc123#decryptionKey=...
console.log(result.id); // abc123
```
### API
#### `createSecret(options: SecretOptions): Promise<CreateSecretResult>`
Creates an encrypted secret on a Hemmelig server.
**Options:**
| Property | Type | Default | Description |
| ----------- | --------------- | ------------------------ | ----------------------------- |
| `secret` | `string` | required | The secret content to encrypt |
| `title` | `string` | - | Optional title |
| `password` | `string` | - | Password protection |
| `expiresIn` | `ExpirationKey` | `'1d'` | Expiration time |
| `views` | `number` | `1` | Max views (1-9999) |
| `burnable` | `boolean` | `true` | Burn on first view |
| `baseUrl` | `string` | `'https://hemmelig.app'` | Server URL |
**Returns:**
| Property | Type | Description |
| ----------- | -------- | ----------------------------- |
| `url` | `string` | Full URL to access the secret |
| `id` | `string` | The secret ID |
| `expiresIn` | `string` | The expiration time set |
## CI/CD Integration
### GitHub Actions
```yaml
- name: Share deployment credentials
run: |
SECRET_URL=$(npx hemmelig "${{ secrets.DEPLOY_KEY }}" \
-t "Deployment Key" \
-e 1h)
echo "Secret URL: $SECRET_URL"
```
### GitLab CI
```yaml
share-secret:
script:
- SECRET_URL=$(npx hemmelig "$DB_PASSWORD" -e 4h)
- echo "Secret URL: $SECRET_URL"
```
## Security
- All encryption happens locally using AES-256-GCM
- Keys are derived using PBKDF2 with 600,000 iterations
- The decryption key is in the URL fragment (`#decryptionKey=...`), which is never sent to the server
- The server only stores encrypted data
## License
MIT

54
cli/package-lock.json generated Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "hemmelig",
"version": "7.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hemmelig",
"version": "7.0.0",
"license": "MIT",
"bin": {
"hemmelig": "dist/bin.js"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@types/node": {
"version": "20.19.26",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.26.tgz",
"integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

54
cli/package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "hemmelig",
"version": "7.0.0",
"description": "CLI for creating encrypted, self-destructing secrets via Hemmelig",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"hemmelig": "dist/bin.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"keywords": [
"hemmelig",
"secret",
"secrets",
"encryption",
"cli",
"security",
"privacy",
"self-destruct",
"one-time",
"share"
],
"author": "Hemmelig",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/HemmeligOrg/Hemmelig.app.git",
"directory": "cli"
},
"bugs": {
"url": "https://github.com/HemmeligOrg/Hemmelig.app/issues"
},
"homepage": "https://hemmelig.app",
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.8.3"
}
}

197
cli/src/bin.ts Normal file
View 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
View 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,
};
}

19
cli/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}