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:
7
cli-go/.gitignore
vendored
Normal file
7
cli-go/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Binary
|
||||
hemmelig
|
||||
hemmelig-*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
126
cli-go/README.md
Normal file
126
cli-go/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Hemmelig CLI (Go)
|
||||
|
||||
A standalone Go binary for creating encrypted, self-destructing secrets via [Hemmelig](https://hemmelig.app).
|
||||
|
||||
```
|
||||
_ _ _ _
|
||||
| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _
|
||||
| |_| |/ _ \ '_ ` _ \| '_ ` _ \ / _ \ | |/ _` |
|
||||
| _ | __/ | | | | | | | | | | __/ | | (_| |
|
||||
|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, |
|
||||
|___/
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
### Download Binary
|
||||
|
||||
Download the pre-built binary for your platform from the [CLI releases](https://github.com/HemmeligOrg/Hemmelig.app/releases?q=cli-v&expanded=true).
|
||||
|
||||
Replace `VERSION` below with the desired version (e.g., `1.0.0`):
|
||||
|
||||
#### Linux (amd64)
|
||||
|
||||
```bash
|
||||
VERSION=1.0.1
|
||||
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-amd64 -o hemmelig
|
||||
chmod +x hemmelig
|
||||
sudo mv hemmelig /usr/local/bin/
|
||||
```
|
||||
|
||||
#### Linux (arm64)
|
||||
|
||||
```bash
|
||||
VERSION=1.0.1
|
||||
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-linux-arm64 -o hemmelig
|
||||
chmod +x hemmelig
|
||||
sudo mv hemmelig /usr/local/bin/
|
||||
```
|
||||
|
||||
#### macOS (Apple Silicon)
|
||||
|
||||
```bash
|
||||
VERSION=1.0.1
|
||||
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-arm64 -o hemmelig
|
||||
chmod +x hemmelig
|
||||
sudo mv hemmelig /usr/local/bin/
|
||||
```
|
||||
|
||||
#### macOS (Intel)
|
||||
|
||||
```bash
|
||||
VERSION=1.0.1
|
||||
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/hemmelig-darwin-amd64 -o hemmelig
|
||||
chmod +x hemmelig
|
||||
sudo mv hemmelig /usr/local/bin/
|
||||
```
|
||||
|
||||
#### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
$VERSION = "1.0.1"
|
||||
Invoke-WebRequest -Uri "https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v$VERSION/hemmelig-windows-amd64.exe" -OutFile "hemmelig.exe"
|
||||
# Move to a directory in your PATH, e.g.:
|
||||
Move-Item hemmelig.exe "$env:LOCALAPPDATA\Microsoft\WindowsApps\hemmelig.exe"
|
||||
```
|
||||
|
||||
#### Verify Download
|
||||
|
||||
```bash
|
||||
VERSION=1.0.1
|
||||
curl -L https://github.com/HemmeligOrg/Hemmelig.app/releases/download/cli-v${VERSION}/checksums.txt -o checksums.txt
|
||||
sha256sum -c checksums.txt --ignore-missing
|
||||
```
|
||||
|
||||
### Build from Source
|
||||
|
||||
```bash
|
||||
go build -o hemmelig .
|
||||
```
|
||||
|
||||
## 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"
|
||||
|
||||
# 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 |
|
||||
|
||||
## 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
|
||||
5
cli-go/go.mod
Normal file
5
cli-go/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module github.com/HemmeligOrg/hemmelig-cli
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require golang.org/x/crypto v0.45.0
|
||||
2
cli-go/go.sum
Normal file
2
cli-go/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
343
cli-go/main.go
Normal file
343
cli-go/main.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/pbkdf2"
|
||||
)
|
||||
|
||||
const version = "1.0.2"
|
||||
|
||||
var expirationTimes = map[string]int{
|
||||
"5m": 300,
|
||||
"30m": 1800,
|
||||
"1h": 3600,
|
||||
"4h": 14400,
|
||||
"12h": 43200,
|
||||
"1d": 86400,
|
||||
"3d": 259200,
|
||||
"7d": 604800,
|
||||
"14d": 1209600,
|
||||
"28d": 2419200,
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Secret string
|
||||
Title string
|
||||
Password string
|
||||
Expires string
|
||||
Views int
|
||||
Burnable bool
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type SecretResponse struct {
|
||||
ID string `json:"id"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func generateKey() string {
|
||||
b := make([]byte, 24)
|
||||
rand.Read(b)
|
||||
encoded := base64.URLEncoding.EncodeToString(b)
|
||||
if len(encoded) > 32 {
|
||||
return encoded[:32]
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func generateSalt() string {
|
||||
return generateKey()
|
||||
}
|
||||
|
||||
func deriveKey(password, salt string) []byte {
|
||||
return pbkdf2.Key([]byte(password), []byte(salt), 1300000, 32, sha256.New)
|
||||
}
|
||||
|
||||
func encrypt(data []byte, encryptionKey, salt string) ([]byte, error) {
|
||||
key := deriveKey(encryptionKey, salt)
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iv := make([]byte, 12)
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ciphertext := aesGCM.Seal(nil, iv, data, nil)
|
||||
|
||||
// Format: IV (12 bytes) + ciphertext (includes auth tag)
|
||||
result := make([]byte, len(iv)+len(ciphertext))
|
||||
copy(result, iv)
|
||||
copy(result[len(iv):], ciphertext)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func uint8ArrayToObject(data []byte) map[string]int {
|
||||
obj := make(map[string]int)
|
||||
for i, b := range data {
|
||||
obj[strconv.Itoa(i)] = int(b)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
func createSecret(opts Options) (string, error) {
|
||||
encryptionKey := opts.Password
|
||||
if encryptionKey == "" {
|
||||
encryptionKey = generateKey()
|
||||
}
|
||||
salt := generateSalt()
|
||||
|
||||
encryptedSecret, err := encrypt([]byte(opts.Secret), encryptionKey, salt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt secret: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"secret": uint8ArrayToObject(encryptedSecret),
|
||||
"salt": salt,
|
||||
"expiresAt": expirationTimes[opts.Expires],
|
||||
"views": opts.Views,
|
||||
"isBurnable": opts.Burnable,
|
||||
}
|
||||
|
||||
if opts.Title != "" {
|
||||
encryptedTitle, err := encrypt([]byte(opts.Title), encryptionKey, salt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encrypt title: %w", err)
|
||||
}
|
||||
payload["title"] = uint8ArrayToObject(encryptedTitle)
|
||||
}
|
||||
|
||||
if opts.Password != "" {
|
||||
payload["password"] = opts.Password
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(
|
||||
opts.BaseURL+"/api/secrets",
|
||||
"application/json",
|
||||
bytes.NewBuffer(jsonData),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create secret: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var result SecretResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
errMsg := result.Error
|
||||
if errMsg == "" {
|
||||
errMsg = "Unknown error"
|
||||
}
|
||||
return "", fmt.Errorf("failed to create secret: %s", errMsg)
|
||||
}
|
||||
|
||||
var url string
|
||||
if opts.Password != "" {
|
||||
url = fmt.Sprintf("%s/secret/%s", opts.BaseURL, result.ID)
|
||||
} else {
|
||||
url = fmt.Sprintf("%s/secret/%s#decryptionKey=%s", opts.BaseURL, result.ID, encryptionKey)
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
fmt.Print(`
|
||||
_ _ _ _
|
||||
| | | | ___ _ __ ___ _ __ ___ ___| (_) __ _
|
||||
| |_| |/ _ \ '_ ` + "`" + ` _ \| '_ ` + "`" + ` _ \ / _ \ | |/ _` + "`" + ` |
|
||||
| _ | __/ | | | | | | | | | | __/ | | (_| |
|
||||
|_| |_|\___|_| |_| |_|_| |_| |_|\___|_|_|\__, |
|
||||
|___/
|
||||
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
|
||||
`)
|
||||
}
|
||||
|
||||
func readStdin() string {
|
||||
stat, _ := os.Stdin.Stat()
|
||||
if (stat.Mode() & os.ModeCharDevice) != 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimRight(string(data), "\n\r")
|
||||
}
|
||||
|
||||
func parseArgs(args []string) (Options, bool, bool) {
|
||||
opts := Options{
|
||||
Expires: "1d",
|
||||
Views: 1,
|
||||
Burnable: true,
|
||||
BaseURL: "https://hemmelig.app",
|
||||
}
|
||||
|
||||
showHelp := false
|
||||
showVersion := false
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
|
||||
switch arg {
|
||||
case "-h", "--help", "/?":
|
||||
showHelp = true
|
||||
case "--version":
|
||||
showVersion = true
|
||||
case "-t", "--title":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
opts.Title = args[i]
|
||||
}
|
||||
case "-p", "--password":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
opts.Password = args[i]
|
||||
}
|
||||
case "-e", "--expires":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
opts.Expires = args[i]
|
||||
}
|
||||
case "-v", "--views":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
v, err := strconv.Atoi(args[i])
|
||||
if err == nil {
|
||||
opts.Views = v
|
||||
}
|
||||
}
|
||||
case "-b", "--burnable":
|
||||
opts.Burnable = true
|
||||
case "--no-burnable":
|
||||
opts.Burnable = false
|
||||
case "-u", "--url":
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
opts.BaseURL = args[i]
|
||||
}
|
||||
default:
|
||||
if !strings.HasPrefix(arg, "-") && opts.Secret == "" {
|
||||
opts.Secret = arg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return opts, showHelp, showVersion
|
||||
}
|
||||
|
||||
func main() {
|
||||
opts, showHelp, showVersion := parseArgs(os.Args[1:])
|
||||
|
||||
if showVersion {
|
||||
fmt.Println(version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if showHelp {
|
||||
printHelp()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if opts.Secret == "" {
|
||||
opts.Secret = readStdin()
|
||||
}
|
||||
|
||||
if opts.Secret == "" {
|
||||
fmt.Fprintln(os.Stderr, "Error: No secret provided. Use --help for usage information.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if _, ok := expirationTimes[opts.Expires]; !ok {
|
||||
fmt.Fprintf(os.Stderr, "Error: Invalid expiration time \"%s\".\n", opts.Expires)
|
||||
fmt.Fprintln(os.Stderr, "Valid options: 5m, 30m, 1h, 4h, 12h, 1d, 3d, 7d, 14d, 28d")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if opts.Views < 1 || opts.Views > 9999 {
|
||||
fmt.Fprintln(os.Stderr, "Error: Views must be between 1 and 9999.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
url, err := createSecret(opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println(url)
|
||||
}
|
||||
Reference in New Issue
Block a user