implement client sided encryption and decryption

This commit is contained in:
sevi-py
2025-06-15 20:25:45 +02:00
parent 0dffaa2e04
commit 3f51f3030b
11 changed files with 452 additions and 182 deletions

View File

@@ -18,15 +18,16 @@ A secure, self-hosted URL shortener with end-to-end encryption. Perfect for priv
## end-to-end Encryption Process
1. **ID Generation**
- Unique random ID created for each link (e.g. `R53nSAg`)
- Example: `google.com``tnyr.me/R53nSAg`
- Unique random ID created for each link (e.g. `iA4y6jMjFk`)
- Example: `google.com``tnyr.me/#iA4y6jMjFk`
2. **Hashing**
- Two Argon2 hashes are calculated by using different salts
- Two Scrypt hashes are calculated by using different salts
- Original URL encrypted with AES-256 using Hash 2
- The whole encryption and decryption process happens in the browser
3. **Storage**
- Original URL encrypted with AES-256-GCM using Hash 2
- Only Hash 1 (storage key) is saved in database
- Only Hash 1 (storage key) and the encrypted URL are saved in database
## Development Setup

File diff suppressed because one or more lines are too long

110
backend/dist/assets/index-CQa4OsAb.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,39 +1,39 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="tnyr.me" />
<link rel="manifest" href="/favicon/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="An easy to use end-to-end encrypted URL shortener. tnyr.me is privacy friendly and does not track you or store cookies" />
<link rel="canonical" href="https://tnyr.me" />
<meta property="og:title" content="TNYR - Privacy friendly URL shortener" />
<meta property="og:site_name" content="tynr.me">
<meta property="og:url" content="https://tnyr.me">
<meta property="og:description" content="A self encrypted url shortener that puts your privacy first, while being easy to use. tnyr.me doesn't track users, store cookies or log your requests.">
<meta property="og:type" content="website">
<meta property="og:image" content="https://tnyr.me/meta/logo.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "TNYR",
"url": "https://tnyr.me",
"description": "A self encrypted url shortener that puts your privacy first, while being easy to use. tnyr.me doesn't track users, store cookies or log your requests."
}
</script>
<title>tnyr.me - Privacy friendly URL shortener</title>
<script type="module" crossorigin src="/assets/index-Dqm_4jPr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DAWtUNlD.css">
</head>
<body>
<div id="root"></div>
</body>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-title" content="tnyr.me" />
<link rel="manifest" href="/favicon/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="An easy to use end-to-end encrypted URL shortener. tnyr.me is privacy friendly and does not track you or store cookies" />
<link rel="canonical" href="https://tnyr.me" />
<meta property="og:title" content="TNYR - Privacy friendly URL shortener" />
<meta property="og:site_name" content="tynr.me">
<meta property="og:url" content="https://tnyr.me">
<meta property="og:description" content="A self encrypted url shortener that puts your privacy first, while being easy to use. tnyr.me doesn't track users, store cookies or log your requests.">
<meta property="og:type" content="website">
<meta property="og:image" content="https://tnyr.me/meta/logo.png">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "TNYR",
"url": "https://tnyr.me",
"description": "A self encrypted url shortener that puts your privacy first, while being easy to use. tnyr.me doesn't track users, store cookies or log your requests."
}
</script>
<title>tnyr.me - Privacy friendly URL shortener</title>
<script type="module" crossorigin src="/assets/index-CQa4OsAb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C46YJTI0.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -100,8 +100,8 @@ def decrypt_url(key, iv, ciphertext):
return plaintext.decode()
@app.route('/shorten', methods=['POST'])
def shorten_url():
@app.route('/shorten-server', methods=['POST'])
def shorten_url_server():
data = request.get_json()
if not data or 'url' not in data:
@@ -147,7 +147,64 @@ def shorten_url():
return jsonify({"id": id}), 201
@app.route('/<id>')
@app.route('/shorten', methods=['POST'])
def shorten_url_client():
data = request.get_json()
required_fields = ['LOOKUP_HASH', 'ENCRYTION_SALT', 'IV', 'ENCRYPTED_URL']
if not data or not all(field in data for field in required_fields):
missing_fields = [field for field in required_fields if field not in (data or {})]
return jsonify({"error": f"Missing fields: {', '.join(missing_fields)}"}), 400
lookup_hash = data['LOOKUP_HASH']
try:
encryption_salt = bytes.fromhex(data['ENCRYTION_SALT'])
iv = bytes.fromhex(data['IV'])
encrypted_url = bytes.fromhex(data['ENCRYPTED_URL'])
except (ValueError, TypeError):
return jsonify({"error": "Invalid hex format for salt, IV, or encrypted URL"}), 400
with get_db() as conn:
cur = conn.execute(
"SELECT 1 FROM client_side_urls WHERE lookup_hash = ?",
(lookup_hash,)
)
if cur.fetchone():
return jsonify({"error": "Lookup hash already exists"}), 409
conn.execute(
"INSERT INTO client_side_urls (lookup_hash, encryption_salt, iv, encrypted_url) VALUES (?, ?, ?, ?)",
(lookup_hash, encryption_salt, iv, encrypted_url)
)
conn.commit()
return jsonify({"message": "URL shortened successfully"}), 201
@app.route('/get-encrypted-url', methods=['GET'])
def get_encrypted_url():
lookup_hash = request.args.get('lookup_hash')
if not lookup_hash:
return jsonify({"error": "Missing lookup_hash parameter"}), 400
with get_db() as conn:
cur = conn.execute(
"SELECT encryption_salt, iv, encrypted_url FROM client_side_urls WHERE lookup_hash = ?",
(lookup_hash,)
)
row = cur.fetchone()
if not row:
return jsonify({"error": "Link not found"}), 404
return jsonify({
"ENCRYTION_SALT": row['encryption_salt'].hex(),
"IV": row['iv'].hex(),
"ENCRYPTED_URL": row['encrypted_url'].hex()
}), 200
@app.route('/<id>') # Still needed for old links
def redirect_url(id):
id_bytes = id.encode()

View File

@@ -2,4 +2,11 @@ CREATE TABLE IF NOT EXISTS urls (
lookup_hash TEXT PRIMARY KEY,
iv BLOB NOT NULL,
encrypted_url BLOB NOT NULL
);
CREATE TABLE IF NOT EXISTS client_side_urls (
lookup_hash TEXT PRIMARY KEY,
encryption_salt BLOB NOT NULL,
iv BLOB NOT NULL,
encrypted_url BLOB NOT NULL
);

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@icons-pack/react-simple-icons": "^11.2.0",
"@noble/hashes": "^1.8.0",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-slot": "^1.1.1",
@@ -1033,6 +1034,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@icons-pack/react-simple-icons": "^11.2.0",
"@noble/hashes": "^1.8.0",
"@radix-ui/react-dropdown-menu": "^2.1.5",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-slot": "^1.1.1",

View File

@@ -1,11 +1,12 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import axios from "axios";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { scrypt } from "@noble/hashes/scrypt";
import { Input } from "./components/ui/input";
import { Button } from "./components/ui/button";
import { Shield, Key, Hash, Lock, Copy, EyeOff, Github } from "lucide-react";
import { Shield, Key, Hash, Lock, Copy, EyeOff, Github, Loader2 } from "lucide-react";
import { SiBuymeacoffee } from "@icons-pack/react-simple-icons";
const urlSchema = z.object({
@@ -27,9 +28,143 @@ const urlSchema = z.object({
type UrlFormData = z.infer<typeof urlSchema>;
// Configuration constants
const ALLOWED_CHARS = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ123456789+*-';
// Shared salt for lookup hash (protects against rainbow tables)
const LOOKUP_SALT = new Uint8Array([0x74, 0x6e, 0x79, 0x72, 0x2e, 0x6d, 0x65, 0x5f, 0x6c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x5f, 0x73]); // "tnyr.me_lookup_s"
// Crypto utilities
const generateRandomString = (length: number, chars: string = ALLOWED_CHARS) => {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return Array.from(array, byte => chars[byte % chars.length]).join('');
};
const generateRandomBytes = (length: number) => {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return array;
};
// Hash ID for lookup (using shared salt to protect against rainbow tables)
const hashIdForLookup = (id: string) => {
const encoder = new TextEncoder();
return scrypt(encoder.encode(id), LOOKUP_SALT, {
N: 2 ** 17, // CPU/memory cost
r: 8, // block size
p: 1, // parallelism
dkLen: 32 // output length
});
};
// Hash ID with random salt for encryption key
const deriveEncryptionKey = (id: string, salt: Uint8Array) => {
const encoder = new TextEncoder();
return scrypt(encoder.encode(id), salt, {
N: 2 ** 17, // CPU/memory cost
r: 8, // block size
p: 1, // parallelism
dkLen: 32 // output length
});
};
const encryptUrl = async (key: Uint8Array, plaintext: string) => {
const iv = generateRandomBytes(16);
const encoder = new TextEncoder();
const data = encoder.encode(plaintext);
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'AES-CBC' },
false,
['encrypt']
);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-CBC', iv: iv },
cryptoKey,
data
);
return { iv, encrypted: new Uint8Array(encrypted) };
};
const decryptUrl = async (key: Uint8Array, iv: Uint8Array, ciphertext: Uint8Array) => {
const cryptoKey = await crypto.subtle.importKey(
'raw',
key,
{ name: 'AES-CBC' },
false,
['decrypt']
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-CBC', iv: iv },
cryptoKey,
ciphertext
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
};
const arrayToHex = (array: Uint8Array) => {
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
};
const hexToArray = (hex: string) => {
return new Uint8Array(hex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
};
export default function App() {
const [shortened, setShortened] = useState("");
const [loading, setLoading] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
// Check for hash in URL on component mount for decryption
useEffect(() => {
const handleDecryption = async () => {
const hash = window.location.hash.slice(1); // Remove # character
if (hash && hash.length === 10) {
setIsDecrypting(true);
try {
// Allow UI to update before starting heavy computation
await new Promise(resolve => setTimeout(resolve, 20));
// Hash ID directly for lookup (no salt)
const lookupKey = hashIdForLookup(hash);
const lookupHash = arrayToHex(lookupKey);
// Get encrypted data from server
const response = await axios.get(`http://tnyr.me/get-encrypted-url?lookup_hash=${lookupHash}`);
const { ENCRYTION_SALT, IV, ENCRYPTED_URL } = response.data;
// Derive decryption key using the encryption salt
const encryptionSalt = hexToArray(ENCRYTION_SALT);
// Allow UI to stay responsive during heavy computation
await new Promise(resolve => setTimeout(resolve, 15));
const decryptionKey = deriveEncryptionKey(hash, encryptionSalt);
// Decrypt URL
const iv = hexToArray(IV);
const encryptedUrl = hexToArray(ENCRYPTED_URL);
const decryptedUrl = await decryptUrl(decryptionKey, iv, encryptedUrl);
// Redirect to decrypted URL
window.location.href = decryptedUrl;
} catch (error) {
console.error('Failed to decrypt URL:', error);
setIsDecrypting(false);
// Could show an error message to user here
}
}
};
handleDecryption();
}, []);
const {
register,
@@ -47,13 +182,41 @@ export default function App() {
clearErrors();
try {
const response = await axios.post("/shorten", {
url: data.url,
// Allow UI to update before starting heavy computation
await new Promise(resolve => setTimeout(resolve, 20));
// Generate random values
const linkId = generateRandomString(10);
const encryptionSalt = generateRandomBytes(16);
// Derive keys
const lookupKey = hashIdForLookup(linkId); // Hash ID directly for lookup
// Allow UI to stay responsive during second hash computation
await new Promise(resolve => setTimeout(resolve, 15));
const encryptionKey = deriveEncryptionKey(linkId, encryptionSalt); // Use random salt for encryption
// Encrypt URL
let url = data.url;
if (!url.startsWith('https://') && !url.startsWith('http://') && !url.startsWith('magnet:')) {
url = 'http://' + url;
}
const { iv, encrypted } = await encryptUrl(encryptionKey, url);
// Send to server
await axios.post("http://tnyr.me/shorten", {
LOOKUP_HASH: arrayToHex(lookupKey),
ENCRYTION_SALT: arrayToHex(encryptionSalt),
IV: arrayToHex(iv),
ENCRYPTED_URL: arrayToHex(encrypted)
});
const shortUrl = `tnyr.me/${response.data.id}`;
const shortUrl = `tnyr.me/#${linkId}`;
setShortened(shortUrl);
} catch {
// Simplified error handling - all errors go to root
} catch (error) {
console.error('Encryption error:', error);
setError("root", { message: "Error shortening URL. Please try again." });
} finally {
setLoading(false);
@@ -64,6 +227,34 @@ export default function App() {
navigator.clipboard.writeText(shortened);
};
// Decryption loading screen
if (isDecrypting) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-slate-100 flex flex-col items-center justify-center p-4">
<div className="text-center space-y-8">
<div className="flex justify-center">
<div className="relative">
<Loader2 className="w-16 h-16 text-indigo-400 animate-spin" />
<div className="absolute inset-0 w-16 h-16 border-4 border-indigo-400/20 rounded-full"></div>
</div>
</div>
<div className="space-y-4">
<h1 className="text-3xl font-bold">Decrypting URL</h1>
<p className="text-slate-400 text-lg max-w-md">
Computing hashes and securely retrieving your destination...
</p>
</div>
<div className="flex items-center justify-center gap-2 text-slate-500">
<Lock className="w-4 h-4" />
<span className="text-sm">End-to-end encrypted</span>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-slate-100 flex flex-col items-center justify-center p-4">
<div className="w-full max-w-2xl space-y-8">
@@ -79,8 +270,7 @@ export default function App() {
<div className="flex items-center gap-2 justify-center">
<Lock className="w-5 h-5" />
<p className="text-center">
Your links are encrypted - we can't see your destination URLs or
share your links!
Your links are end-to-end encrypted - your original URL never leaves your browser unencrypted.
</p>
</div>
</div>
@@ -149,32 +339,14 @@ export default function App() {
</div>
<div>
<h3 className="font-medium mb-1">
Zero-Knowledge Encryption
End-to-End Encryption
</h3>
<p className="text-slate-400 text-sm">
Your URL is encrypted using AES-256 with a key derived from
your unique link ID. Not even we can decrypt or view your
original URL.
Your URL is encrypted in your browser and never sent to our servers in plaintext. Using AES-256 encryption, only you and those you share the link with can see the destination.
</p>
</div>
</div>
<div className="flex gap-3">
<div className="mt-1">
<Key className="w-5 h-5 text-indigo-400" />
</div>
<div>
<h3 className="font-medium mb-1">Secure Storage</h3>
<p className="text-slate-400 text-sm">
We generate two separate hashes - one for identification and
another for encrypting the destination. Without the exact
ID, the link is completely inaccessible.
</p>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex gap-3">
<div className="mt-1">
<Hash className="w-5 h-5 text-indigo-400" />
@@ -187,6 +359,20 @@ export default function App() {
</p>
</div>
</div>
</div>
<div className="space-y-4">
<div className="flex gap-3">
<div className="mt-1">
<Key className="w-5 h-5 text-indigo-400" />
</div>
<div>
<h3 className="font-medium mb-1">Secure By Design</h3>
<p className="text-slate-400 text-sm">
We derive two separate keys from your link ID. One is used to create a lookup hash, so we can find your encrypted data. The other is used to encrypt your destination URL. Without the original link ID, the data is just random noise.
</p>
</div>
</div>
<div className="flex gap-3">
<div className="mt-1">