├── favicon.ico ├── .gitignore ├── LICENSE ├── index.html ├── hidden ├── decrypt-bookmarklet.js ├── hidden.js ├── README.md └── index.html ├── bruteforce ├── bruteforce.js └── index.html ├── api.js ├── favicon.svg ├── decrypt ├── decrypt.js └── index.html ├── index.js ├── style.css ├── b64.js ├── create ├── create.js └── index.html ├── corner-ribbon-minified.svg ├── README.md └── corner-ribbon.svg /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jstrieb/link-lock/HEAD/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Vim temporary swap files 2 | *.swp 3 | *.swo 4 | 5 | # Backed up copies of old files 6 | *.bak 7 | 8 | # Generated SSL key file for SSL python http.server 9 | *.pem 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jacob Strieb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Link Lock - Password-protect links 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 35 | 36 | 46 | 47 | 48 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /hidden/decrypt-bookmarklet.js: -------------------------------------------------------------------------------- 1 | (() => { 2 | var b64 = (() => { 3 | function generateIndexDict(a) { 4 | let result = {}; 5 | for (let i = 0; i < a.length; i++) { 6 | result[a[i]] = i; 7 | } 8 | return result; 9 | } 10 | 11 | const _a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 12 | const _aRev = generateIndexDict(_a); 13 | _aRev["-"] = _aRev["+"]; 14 | _aRev["_"] = _aRev["/"]; 15 | 16 | const _enc = new TextEncoder("utf-8"); 17 | const _dec = new TextDecoder("utf-8"); 18 | 19 | return { 20 | 21 | decode: function(s) { 22 | return this.binaryToAscii(this.base64ToBinary(s)); 23 | }, 24 | 25 | encode: function(s) { 26 | return this.binaryToBase64(this.asciiToBinary(s)); 27 | }, 28 | 29 | asciiToBinary: function(text) { 30 | return _enc.encode(text); 31 | }, 32 | 33 | 34 | binaryToAscii: function(binary) { 35 | return _dec.decode(binary); 36 | }, 37 | 38 | 39 | binaryToBase64: function(originalBytes) { 40 | let length = originalBytes.length; 41 | let added = (length % 3 == 0) ? 0 : (3 - length % 3); 42 | let bytes = new Uint8Array(length + added); 43 | bytes.set(originalBytes); 44 | 45 | let output = ""; 46 | for (let i = 0; i < bytes.length; i += 3) { 47 | output += _a[ bytes[i] >>> 2 ]; 48 | output += _a[ ((bytes[i] & 0x3) << 4) | (bytes[i + 1] >>> 4) ]; 49 | output += _a[ ((bytes[i + 1] & 0xF) << 2) | (bytes[i + 2] >>> 6) ]; 50 | output += _a[ bytes[i + 2] & 0x3F ]; 51 | } 52 | 53 | if (added > 0) { 54 | output = output.slice(0, -added) + ("=".repeat(added)); 55 | } 56 | 57 | return output; 58 | }, 59 | 60 | base64ToBinary: function(s) { 61 | let bytes = []; 62 | 63 | if (s.length % 4 == 1) { 64 | throw "Invalid base64 input"; 65 | } else if (s.length % 4 != 0) { 66 | s += "=".repeat(4 - (s.length % 4)); 67 | } 68 | 69 | for (let i = 0; i <= (s.length - 4); i += 4) { 70 | for (let j = 0; j < 4; j++) { 71 | if (s[i + j] != "=" && !(s[i + j] in _aRev)) { 72 | throw "Invalid base64 input"; 73 | } else if (s[i + j] == "=" && Math.abs(s.length - (i + j)) > 2) { 74 | throw "Invalid base64 input"; 75 | } 76 | } 77 | 78 | bytes.push((_aRev[s[i]] << 2) | (_aRev[s[i + 1]] >>> 4)); 79 | if (s[i + 2] != "=") { 80 | bytes.push(((_aRev[s[i + 1]] & 0xF) << 4) | (_aRev[s[i + 2]] >>> 2)); 81 | } 82 | if (s[i + 3] != "=") { 83 | bytes.push(((_aRev[s[i + 2]] & 0x3) << 6) | _aRev[s[i + 3]]); 84 | } 85 | } 86 | 87 | return new Uint8Array(bytes); 88 | } 89 | 90 | } 91 | })(); 92 | 93 | const hash = window.location.hash.slice(1); 94 | try { 95 | const decoded = b64.decode(hash); 96 | const params = JSON.parse(decoded); 97 | if (params.unencrypted) { 98 | window.location.href = params.url; 99 | } else { 100 | window.location.href = "https://jstrieb.github.io/link-lock/" + window.location.hash; 101 | } 102 | } catch { 103 | window.location.replace("https://gmail.com"); 104 | } 105 | })(); 106 | -------------------------------------------------------------------------------- /bruteforce/bruteforce.js: -------------------------------------------------------------------------------- 1 | function error(text) { 2 | const alert = document.querySelector(".alert"); 3 | alert.innerText = text; 4 | alert.style.opacity = 1; 5 | } 6 | 7 | function onBruteForce() { 8 | if (!("importKey" in window.crypto.subtle)) { 9 | error("window.crypto not loaded. Please reload over https"); 10 | return; 11 | } 12 | if (!("b64" in window && "apiVersions" in window)) { 13 | error("Important libraries not loaded!"); 14 | return; 15 | } 16 | 17 | const urlText = document.querySelector("#encrypted-url").value; 18 | let url; 19 | try { 20 | url = new URL(urlText); 21 | } catch { 22 | error("Entered text is not a valid URL. Make sure it includes \"https://\" too!"); 23 | return; 24 | } 25 | 26 | let params; 27 | try { 28 | params = JSON.parse(b64.decode(url.hash.slice(1))); 29 | } catch { 30 | error("The link appears corrupted."); 31 | return; 32 | } 33 | 34 | if (!("v" in params && "e" in params)) { 35 | error("The link appears corrupted. The encoded URL is missing necessary parameters."); 36 | return; 37 | } 38 | 39 | if (!(params["v"] in apiVersions)) { 40 | error("Unsupported API version. The link may be corrupted."); 41 | return; 42 | } 43 | 44 | const api = apiVersions[params["v"]]; 45 | 46 | const encrypted = b64.base64ToBinary(params["e"]); 47 | const salt = "s" in params ? b64.base64ToBinary(params["s"]) : null; 48 | const iv = "i" in params ? b64.base64ToBinary(params["i"]) : null; 49 | 50 | const cset = document.querySelector("#charset").value.split(""); 51 | if (charset == "") { 52 | error("Charset cannot be empty."); 53 | return; 54 | } 55 | 56 | var progress = { 57 | tried: 0, 58 | total: 0, 59 | len: 0, 60 | overallTotal: 0, 61 | done: false, 62 | startTime: performance.now() 63 | }; 64 | 65 | async function tryAllLen(prefix, len, curLen) { 66 | if (progress.done) return; 67 | if (len == curLen) { 68 | progress.tried++; 69 | try { 70 | await api.decrypt(encrypted, prefix, salt, iv); 71 | document.querySelector("#output").value = prefix; 72 | progress.done = true; 73 | error("Completed!"); 74 | } catch {} 75 | return; 76 | } 77 | for (let i=0; i < cset.length; i++) { 78 | let c = cset[i]; 79 | await tryAllLen(prefix + c, len, curLen + 1); 80 | } 81 | } 82 | 83 | function progressUpdate() { 84 | if (progress.done) { 85 | clearInterval(); 86 | return; 87 | } 88 | let delta = performance.now() - progress.startTime; 89 | error(`Trying ${progress.total} passwords of length ${progress.len} – ${Math.round(100000 * progress.tried / progress.total)/1000}% complete. Testing ${Math.round(1000000 * (progress.overallTotal + progress.tried) / delta)/1000} passwords per second.`); 90 | } 91 | 92 | (async () => { 93 | for (let len=0; !progress.done; len++) { 94 | progress.overallTotal += progress.tried; 95 | progress.tried = 0; 96 | progress.total = Math.pow(cset.length, len); 97 | progress.len = len; 98 | progressUpdate(); 99 | await tryAllLen("", len, 0); 100 | } 101 | })(); 102 | 103 | setInterval(progressUpdate, 4000); 104 | } 105 | -------------------------------------------------------------------------------- /api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Jacob Strieb 3 | * May 2020 4 | */ 5 | 6 | 7 | 8 | /******************************************************************************* 9 | * Global Variables 10 | ******************************************************************************/ 11 | 12 | var LATEST_API_VERSION = "0.0.1"; 13 | 14 | var apiVersions = {}; 15 | 16 | 17 | 18 | /******************************************************************************* 19 | * API Version 0.0.1 (Latest) 20 | ******************************************************************************/ 21 | 22 | apiVersions["0.0.1"] = { 23 | 24 | // Static salt and initialization vector for shorter, less secure links 25 | salt: Uint8Array.from([236, 231, 167, 249, 207, 95, 201, 235, 164, 98, 246, 26 | 26, 176, 174, 72, 249]), 27 | 28 | iv: Uint8Array.from([255, 237, 148, 105, 6, 255, 123, 202, 115, 130, 16, 29 | 116]), 30 | 31 | 32 | // Generate random salt and initialization vectors 33 | randomSalt: async function() { 34 | return await window.crypto.getRandomValues(new Uint8Array(16)); 35 | }, 36 | 37 | randomIv: async function() { 38 | return await window.crypto.getRandomValues(new Uint8Array(12)); 39 | }, 40 | 41 | 42 | // Import the raw, plain-text password and derive a key using a SHA-256 hash 43 | // and PBKDF2. Use the static salt for this version if one has not been given 44 | deriveKey: async function(password, salt=null) { 45 | let rawKey = await window.crypto.subtle.importKey( 46 | "raw", 47 | b64.asciiToBinary(password), 48 | { name: "PBKDF2" }, 49 | false, 50 | [ "deriveBits", "deriveKey" ] 51 | ); 52 | return await window.crypto.subtle.deriveKey( 53 | { 54 | name: "PBKDF2", 55 | salt: salt == null ? this.salt : salt, 56 | iterations: 100000, 57 | hash: "SHA-256" 58 | }, 59 | rawKey, 60 | { 61 | name: "AES-GCM", 62 | length: 256 63 | }, 64 | true, 65 | [ "encrypt", "decrypt" ] 66 | ); 67 | }, 68 | 69 | 70 | // Encrypt the text using AES-GCM with a key derived from the password. Takes 71 | // in strings for text and password, as well as optional salt and iv. Uses the 72 | // static iv for this version if one is not given. 73 | encrypt: async function(text, password, salt=null, iv=null) { 74 | let key = await this.deriveKey(password, salt=salt); 75 | let encryptedBinary = await window.crypto.subtle.encrypt( 76 | { 77 | name: "AES-GCM", 78 | iv: iv == null ? this.iv : iv 79 | }, 80 | key, 81 | b64.asciiToBinary(text) 82 | ); 83 | return encryptedBinary; 84 | }, 85 | 86 | 87 | // Decrypt the text using AES-GCM with a key derived from the password. Takes 88 | // in text as an ArrayBuffer and a string password, as well as optional salt 89 | // and iv. Uses the static iv for this version if one is not given. 90 | decrypt: async function(text, password, salt=null, iv=null) { 91 | let key = await this.deriveKey(password, salt=salt); 92 | let decryptedBinary = await window.crypto.subtle.decrypt( 93 | { 94 | name: "AES-GCM", 95 | iv: iv == null ? this.iv : iv 96 | }, 97 | key, 98 | new Uint8Array(text) 99 | ); 100 | return b64.binaryToAscii(decryptedBinary); 101 | } 102 | 103 | } 104 | -------------------------------------------------------------------------------- /favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 48 | 58 | 59 | 61 | 63 | 64 | 66 | image/svg+xml 67 | 69 | 70 | 71 | 72 | Jacob Strieb 73 | 74 | 75 | 76 | 77 | 78 | 83 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /bruteforce/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Brute Force Link Lock 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | View on GitHub 26 | 27 | 28 | 29 | 38 | 39 | 40 |

Brute Force Link Lock URLs

41 |

"Brute forcing" a password is the act of guessing it by brute force – literally trying all passwords until one works. This page is a simple, proof-of-concept brute force application. It is designed to decrypt Link Lock URLs by trying every single possible password. It is not optimized in any way, and does minimal error-checking.

42 |

For more information about brute forcing Link Lock URLs, read the open GitHub issue about it.

43 |

For a command-line brute force application that performs parallel processing using all cores of the CPU, check out this project.

44 | 45 |
46 | 47 | 48 |
49 | 50 | 51 | 52 | 53 | 54 |

INVISIBLE

55 |
56 | 57 |
58 | 59 | 60 |
61 | 62 | 63 |
64 | 65 | 66 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /decrypt/decrypt.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Jacob Strieb 3 | * May 2020 4 | */ 5 | 6 | 7 | 8 | /******************************************************************************* 9 | * Helper Functions 10 | ******************************************************************************/ 11 | 12 | // Highlight the text in an input with a given id 13 | function highlight(id) { 14 | let output = document.querySelector("#" + id); 15 | output.focus(); 16 | output.select() 17 | output.setSelectionRange(0, output.value.length + 1); 18 | return output; 19 | } 20 | 21 | 22 | // Display a message in the "alert" area 23 | function error(text) { 24 | const alertText = document.querySelector(".alert"); 25 | alertText.innerText = text; 26 | alertText.style.opacity = 1; 27 | } 28 | 29 | 30 | 31 | /******************************************************************************* 32 | * Main UI Functions 33 | ******************************************************************************/ 34 | 35 | async function onDecrypt() { 36 | // Fail if the b64 library or API was not loaded 37 | if (!("b64" in window && "apiVersions" in window)) { 38 | error("Important libraries not loaded!"); 39 | return; 40 | } 41 | 42 | // Try to get page data from the URL if possible 43 | const urlText = document.querySelector("#encrypted-url").value; 44 | let url; 45 | try { 46 | url = new URL(urlText); 47 | } catch { 48 | error("Entered text is not a valid URL. Make sure it includes \"https://\" too!"); 49 | return; 50 | } 51 | 52 | let params; 53 | try { 54 | params = JSON.parse(b64.decode(url.hash.slice(1))); 55 | } catch { 56 | error("The link appears corrupted."); 57 | return; 58 | } 59 | 60 | // Check that all required parameters encoded in the URL are present 61 | if (!("v" in params && "e" in params)) { 62 | error("The link appears corrupted. The encoded URL is missing necessary parameters."); 63 | return; 64 | } 65 | 66 | // Check that the version in the parameters is valid 67 | if (!(params["v"] in apiVersions)) { 68 | error("Unsupported API version. The link may be corrupted."); 69 | return; 70 | } 71 | 72 | const api = apiVersions[params["v"]]; 73 | 74 | // Get values for decryption 75 | const encrypted = b64.base64ToBinary(params["e"]); 76 | const salt = "s" in params ? b64.base64ToBinary(params["s"]) : null; 77 | const iv = "i" in params ? b64.base64ToBinary(params["i"]) : null; 78 | 79 | const password = document.querySelector("#password").value; 80 | 81 | // Decrypt if possible 82 | let decrypted; 83 | try { 84 | decrypted = await api.decrypt(encrypted, password, salt, iv); 85 | } catch { 86 | error("Incorrect password!"); 87 | return; 88 | } 89 | 90 | // Print the decrypted link to the output area 91 | document.querySelector("#output").value = decrypted; 92 | error("Decrypted!"); 93 | 94 | // Update the "Open in New Tab" button to link to the correct place 95 | document.querySelector("#open").href = decrypted; 96 | } 97 | 98 | 99 | // Activated when the "Copy" button is pressed 100 | function onCopy(id) { 101 | // Select and copy 102 | const output = highlight(id); 103 | document.execCommand("copy"); 104 | 105 | // Alert the user that the text was successfully copied 106 | const alertArea = document.querySelector("#copy-alert"); 107 | alertArea.innerText = `Copied ${output.value.length} characters`; 108 | alertArea.style.opacity = "1"; 109 | setTimeout(() => { alertArea.style.opacity = 0; }, 3000); 110 | 111 | // Deselect 112 | output.selectionEnd = output.selectionStart; 113 | output.blur(); 114 | } 115 | 116 | function main() { 117 | if (window.location.hash) { 118 | document.querySelector("#encrypted-url").value = 119 | `https://jstrieb.github.io/link-lock/${window.location.hash}`; 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /decrypt/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Decrypt Link Lock URLs 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | View on GitHub 28 | 29 | 30 | 31 | 40 | 41 | 42 | 47 | 48 |

Decrypt Link Lock URLs

49 |

This application is for decrypting Link Lock URLs without automatically redirecting. This is useful if you do not trust the source of an encrypted URL. It is also useful if the URL uses a blocked protocol like javascript:, for example.

50 | 51 |

This page is also useful if you think you have received a locked link, but it uses another domain, instead of jstrieb.github.io. This may be done as a means to evade censorship.

52 | 53 |
54 | 55 |
56 | 57 | 58 | 59 | 60 | 61 |

INVISIBLE

62 |
63 | 64 |
65 | 66 | 67 |
68 | 69 | 70 | 71 | 72 |

Copied

73 |
74 | 75 | 76 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | function error(text) { 2 | document.querySelector(".form").style.display = "none"; 3 | document.querySelector(".error").style.display = "inherit"; 4 | document.querySelector("#errortext").innerText = `Error: ${text}`; 5 | } 6 | 7 | // Run when the loads 8 | function main() { 9 | if (window.location.hash) { 10 | document.querySelector(".form").style.display = "inherit"; 11 | document.querySelector("#password").value = ""; 12 | document.querySelector("#password").focus(); 13 | document.querySelector(".error").style.display = "none"; 14 | document.querySelector("#errortext").innerText = ""; 15 | 16 | // Fail if the b64 library or API was not loaded 17 | if (!("b64" in window)) { 18 | error("Base64 library not loaded."); 19 | return; 20 | } 21 | if (!("apiVersions" in window)) { 22 | error("API library not loaded."); 23 | return; 24 | } 25 | 26 | // Try to get page data from the URL if possible 27 | const hash = window.location.hash.slice(1); 28 | let params; 29 | try { 30 | params = JSON.parse(b64.decode(hash)); 31 | } catch { 32 | error("The link appears corrupted."); 33 | return; 34 | } 35 | 36 | // Check that all required parameters encoded in the URL are present 37 | if (!("v" in params && "e" in params)) { 38 | error("The link appears corrupted. The encoded URL is missing necessary parameters."); 39 | return; 40 | } 41 | 42 | // Check that the version in the parameters is valid 43 | if (!(params["v"] in apiVersions)) { 44 | error("Unsupported API version. The link may be corrupted."); 45 | return; 46 | } 47 | 48 | const api = apiVersions[params["v"]]; 49 | 50 | // Get values for decryption 51 | const encrypted = b64.base64ToBinary(params["e"]); 52 | const salt = "s" in params ? b64.base64ToBinary(params["s"]) : null; 53 | const iv = "i" in params ? b64.base64ToBinary(params["i"]) : null; 54 | 55 | let hint, password; 56 | if ("h" in params) { 57 | hint = params["h"]; 58 | document.querySelector("#hint").innerText = "Hint: " + hint; 59 | } 60 | 61 | const unlockButton = document.querySelector("#unlockbutton"); 62 | const passwordPrompt = document.querySelector("#password"); 63 | passwordPrompt.addEventListener("keypress", (e) => { 64 | if (e.key === "Enter") { 65 | unlockButton.click(); 66 | } 67 | }); 68 | unlockButton.addEventListener("click", async () => { 69 | password = passwordPrompt.value; 70 | 71 | // Decrypt and redirect if possible 72 | let url; 73 | try { 74 | url = await api.decrypt(encrypted, password, salt, iv); 75 | } catch { 76 | // Password is incorrect. 77 | error("Password is incorrect."); 78 | 79 | // Set the "decrypt without redirect" URL appropriately 80 | document.querySelector("#no-redirect").href = 81 | `https://jstrieb.github.io/link-lock/decrypt/#${hash}`; 82 | 83 | // Set the "create hidden bookmark" URL appropriately 84 | document.querySelector("#hidden").href = 85 | `https://jstrieb.github.io/link-lock/hidden/#${hash}`; 86 | return; 87 | } 88 | 89 | try { 90 | // Extra check to make sure the URL is valid. Probably shouldn't fail. 91 | let urlObj = new URL(url); 92 | 93 | // Prevent XSS by making sure only HTTP URLs are used. Also allow magnet 94 | // links for password-protected torrents. 95 | if (!(urlObj.protocol == "http:" 96 | || urlObj.protocol == "https:" 97 | || urlObj.protocol == "magnet:")) { 98 | error(`The link uses a non-hypertext protocol, which is not allowed. ` 99 | + `The URL begins with "${urlObj.protocol}" and may be malicious.`); 100 | return; 101 | } 102 | 103 | // IMPORTANT NOTE: must use window.location.href instead of the (in my 104 | // opinion more proper) window.location.replace. If you use replace, it 105 | // causes Chrome to change the icon of a bookmarked link to update it to 106 | // the unlocked destination. This is dangerous information leakage. 107 | window.location.href = url; 108 | } catch { 109 | error("A corrupted URL was encrypted. Cannot redirect."); 110 | console.log(url); 111 | return; 112 | } 113 | }); 114 | } else { 115 | // Otherwise redirect to the creator 116 | window.location.replace("./create"); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Jacob Strieb 3 | * May 2020 4 | */ 5 | 6 | 7 | /******************************************************************************* 8 | * Element styles 9 | ******************************************************************************/ 10 | 11 | html { 12 | font-family: sans-serif; 13 | padding: 10px; 14 | } 15 | 16 | body { 17 | padding: 15px; 18 | display: block; 19 | margin: auto; 20 | max-width: 66ch; 21 | } 22 | 23 | p { 24 | text-align: justify; 25 | } 26 | 27 | ul, ol { 28 | padding-left: 1em; 29 | } 30 | 31 | ol li { 32 | margin-bottom: 1em; 33 | } 34 | 35 | button { 36 | padding: 11px; 37 | background: rgb(95, 158, 160); 38 | color: white; 39 | border: 0.5px solid white; 40 | border-radius: 5px; 41 | margin-top: 5px; 42 | margin-right: 5px; 43 | cursor: pointer; 44 | font-weight: bold; 45 | white-space: nowrap; 46 | } 47 | 48 | label { 49 | display: block; 50 | font-variant: small-caps; 51 | margin-bottom: 5px; 52 | } 53 | 54 | summary { 55 | font-variant: small-caps; 56 | margin-bottom: 5px; 57 | user-select: none; 58 | } 59 | 60 | input, textarea { 61 | padding: 10px; 62 | border: 0.5px solid black; 63 | border-radius: 3px; 64 | margin-bottom: 15px; 65 | box-sizing: border-box; 66 | width: 100%; 67 | resize: none; 68 | font-family: sans-serif; 69 | font-size: 0.9em; 70 | } 71 | 72 | input[type="checkbox"] { 73 | cursor: pointer; 74 | margin-right: 0; 75 | } 76 | 77 | hr { 78 | margin-top: 3em; 79 | margin-bottom: 3em; 80 | border: 0; 81 | border-top: 0.5px solid rgba(200, 200, 200, 1); 82 | } 83 | 84 | footer hr { 85 | margin-bottom: 1em; 86 | border-top: 0.5px solid black; 87 | } 88 | 89 | footer p { 90 | text-align: center; 91 | } 92 | 93 | *[aria-disabled="true"] { 94 | pointer-events: none; 95 | cursor: not-allowed; 96 | user-select: none; 97 | } 98 | 99 | code { 100 | font-family: monospace, monospace; 101 | padding: 0 3px; 102 | } 103 | 104 | 105 | /******************************************************************************* 106 | * Classes 107 | ******************************************************************************/ 108 | 109 | /* View on GitHub SVG ribbon */ 110 | @media (max-width: 100ch) { 111 | .ribbon { 112 | width: 100px; 113 | height: 100px; 114 | } 115 | } 116 | 117 | .ribbon { 118 | position: absolute; 119 | top: 0; 120 | right: 0; 121 | } 122 | 123 | 124 | /* Error area */ 125 | .red-border { 126 | border: 3px solid red; 127 | padding: 2em; 128 | } 129 | 130 | .error, .error p { 131 | text-align: center; 132 | } 133 | 134 | 135 | /* Password & Confirm Password inputs */ 136 | .split-row { 137 | display: flex; 138 | flex-flow: row wrap; 139 | justify-content: space-between; 140 | align-items: center; 141 | } 142 | 143 | .password { 144 | flex: 1; 145 | } 146 | 147 | .confirm-password { 148 | flex: 1; 149 | margin-left: 20px; 150 | } 151 | 152 | 153 | 154 | /* Advanced settings */ 155 | #advanced-label { 156 | cursor: pointer; 157 | margin-bottom: 10px; 158 | } 159 | 160 | .advanced { 161 | display: flex; 162 | flex-flow: row wrap; 163 | } 164 | 165 | .advanced .labeled-input { 166 | display: flex; 167 | flex-flow: row-reverse nowrap; 168 | justify-content: flex-end; 169 | } 170 | 171 | .advanced .labeled-input label { 172 | width: auto; 173 | margin-right: 20px; 174 | white-space: nowrap; 175 | cursor: pointer; 176 | } 177 | 178 | .advanced .labeled-input input { 179 | width: auto; 180 | margin-right: 5px; 181 | } 182 | 183 | 184 | /* Output area */ 185 | .output input { 186 | background: rgba(235, 235, 235, 1); 187 | } 188 | 189 | .alert { 190 | opacity: 0; 191 | transition: opacity 0.25s; 192 | } 193 | 194 | 195 | /* Bookmarks that look like buttons, but different */ 196 | .bookmark { 197 | padding: 11px; 198 | background: none; 199 | color: rgb(95, 158, 160); 200 | border: 3px solid; 201 | border-radius: 5px; 202 | font-weight: bold; 203 | text-decoration: none; 204 | margin-top: 5px; 205 | margin-right: 5px; 206 | cursor: pointer; 207 | display: inline-block; 208 | margin: auto; 209 | text-align: center; 210 | } 211 | 212 | .bookmark:hover { 213 | background: rgb(95, 158, 160); 214 | color: white; 215 | border: 3px solid rgb(95, 158, 160); 216 | } 217 | 218 | .bookmark[aria-disabled="true"] { 219 | background: rgba(235, 235, 235, 1); 220 | color: gray; 221 | } 222 | 223 | /* Buttons on the same line as inputs */ 224 | .inline-button-container { 225 | display: flex; 226 | width: 100%; 227 | } 228 | 229 | .inline-button-container * { 230 | margin: 5px; 231 | } 232 | 233 | .inline-button-container input { 234 | margin-left: 0; 235 | } 236 | 237 | -------------------------------------------------------------------------------- /hidden/hidden.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Jacob Strieb 3 | * December 2020 4 | */ 5 | 6 | 7 | 8 | /******************************************************************************* 9 | * Helper Functions 10 | ******************************************************************************/ 11 | 12 | /*** 13 | * Display a message in the "alert" area 14 | */ 15 | function error(text) { 16 | const alertText = document.querySelector(".alert"); 17 | alertText.innerHTML = text; 18 | alertText.style.opacity = 1; 19 | } 20 | 21 | 22 | 23 | /******************************************************************************* 24 | * Main UI Functions 25 | ******************************************************************************/ 26 | 27 | /*** 28 | * Create a hidden bookmark when the form is filled out. 29 | */ 30 | async function onHide() { 31 | // Fail if the b64 library or API was not loaded 32 | if (!("b64" in window && "apiVersions" in window)) { 33 | error("Important libraries not loaded!"); 34 | return; 35 | } 36 | 37 | // Try to get page data from the input hidden URL if possible 38 | let urlText = document.querySelector("#encrypted-url").value; 39 | let hiddenUrl; 40 | try { 41 | hiddenUrl = new URL(urlText); 42 | } catch { 43 | error("Hidden URL is not valid. Make sure it includes \"https://\" too!"); 44 | return; 45 | } 46 | 47 | // Try to get page data from the input bookmark URL if possible 48 | urlText = document.querySelector("#bookmark-url").value; 49 | let bookmarkUrl; 50 | try { 51 | bookmarkUrl = new URL(urlText); 52 | } catch { 53 | error("Bookmark URL is not valid. Make sure it includes \"https://\" too!"); 54 | return; 55 | } 56 | 57 | // Ensure that the Link Lock URL is valid 58 | let hash = hiddenUrl.hash.slice(1); 59 | try { 60 | let _ = JSON.parse(b64.decode(hash)); 61 | } catch { 62 | error("The hidden URL appears corrupted. It must be a password-protected Link Lock URL. Click here to add a password."); 63 | return; 64 | 65 | // Uncomment this to allow hiding arbitrary pages. Not secure though, so I 66 | // disabled it. 67 | /* 68 | let hashData = { 69 | unencrypted: true, 70 | url: hiddenUrl.toString(), 71 | }; 72 | 73 | hiddenUrl.hash = b64.encode(JSON.stringify(hashData)); 74 | document.querySelector("#encrypted-url").value = hiddenUrl.toString(); 75 | */ 76 | } 77 | 78 | let output = document.querySelector("#output"); 79 | 80 | // Set the output href to be the hidden URL with the old URL hash 81 | bookmarkUrl.hash = hiddenUrl.hash; 82 | output.setAttribute("href", bookmarkUrl.toString()); 83 | 84 | // Enable clicking and dragging the output bookmark 85 | output.setAttribute("aria-disabled", "false"); 86 | 87 | // Change the output bookmark title to match the user input 88 | output.innerText = document.querySelector("#bookmark-title").value; 89 | 90 | error("Bookmark created below."); 91 | 92 | // Scroll to the bottom so the user sees where the bookmark was created 93 | window.scrollTo({ 94 | top: document.body.scrollHeight, 95 | behavior: "smooth", 96 | }); 97 | } 98 | 99 | 100 | /*** 101 | * Called when the "change location" button is clicked. Adjusts the destination 102 | * of the decrypt bookmark via regular expressions. 103 | */ 104 | function onChangeDecrypt() { 105 | let newUrl; 106 | try { 107 | const newUrlInput = document.querySelector("#decrypt-bookmark-disguise"); 108 | const _ = new URL(newUrlInput.value); 109 | newUrl = newUrlInput.value; 110 | } catch (_) { 111 | return; 112 | } 113 | 114 | const decryptBookmark = document.querySelector("#decrypt-bookmark"); 115 | decryptBookmark.href = decryptBookmark.href.replace(/replace\("[^"]*"\)/, `replace("${newUrl}")`); 116 | console.log(decryptBookmark.href); 117 | } 118 | 119 | 120 | /*** 121 | * Get a random link from Wikipedia 122 | */ 123 | async function randomLink() { 124 | let page = await fetch("https://en.wikipedia.org/w/api.php?" 125 | + "format=json" 126 | + "&action=query" 127 | + "&generator=random" 128 | + "&grnnamespace=0" /* Only show articles, not users */ 129 | + "&prop=info" 130 | + "&inprop=url" /* Get URLs, they're not there by default */ 131 | + "&origin=*") /* https://mediawiki.org/wiki/API:Cross-site_requests */ 132 | .then(r => r.json()) 133 | .then(d => { 134 | let pages = d.query.pages; 135 | return pages[Object.keys(pages)[0]]; 136 | }); 137 | 138 | document.querySelector("#bookmark-url").value = await page.canonicalurl; 139 | document.querySelector("#bookmark-title").value = await page.title; 140 | } 141 | 142 | 143 | /*** 144 | * If the page has a hash, autofill it. 145 | * 146 | * Run on page load. 147 | */ 148 | function main() { 149 | if (window.location.hash) { 150 | document.querySelector("#encrypted-url").value = 151 | `https://jstrieb.github.io/link-lock/${window.location.hash}`; 152 | 153 | window.location.hash = ""; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /b64.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Jacob Strieb 3 | * May 2020 4 | */ 5 | 6 | var b64 = (function() { 7 | 8 | // Generate a dictionary with {key: val} as {character: index in input string} 9 | function generateIndexDict(a) { 10 | let result = {}; 11 | for (let i = 0; i < a.length; i++) { 12 | result[a[i]] = i; 13 | } 14 | return result; 15 | } 16 | 17 | // Decode URL safe even though it is not the primary encoding mechanism 18 | const _a = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 19 | const _aRev = generateIndexDict(_a); 20 | _aRev["-"] = _aRev["+"]; 21 | _aRev["_"] = _aRev["/"]; 22 | 23 | const _enc = new TextEncoder("utf-8"); 24 | const _dec = new TextDecoder("utf-8"); 25 | 26 | return { 27 | 28 | // Encode a string as base64 29 | decode: function(s) { 30 | return this.binaryToAscii(this.base64ToBinary(s)); 31 | }, 32 | 33 | // Decode base64 to a string 34 | encode: function(s) { 35 | return this.binaryToBase64(this.asciiToBinary(s)); 36 | }, 37 | 38 | // Convert a string to a Uint8Array 39 | // FIXME: TextEncoding and TextDecoding are not actually inverses 40 | asciiToBinary: function(text) { 41 | return _enc.encode(text); 42 | }, 43 | 44 | 45 | // Convert a Uint8Array to a string 46 | // FIXME: TextEncoding and TextDecoding are not actually inverses 47 | binaryToAscii: function(binary) { 48 | return _dec.decode(binary); 49 | }, 50 | 51 | 52 | // Return a base64-encoded string from a Uint8Array input 53 | binaryToBase64: function(originalBytes) { 54 | // Pad the output array to a multiple of 3 bytes 55 | let length = originalBytes.length; 56 | let added = (length % 3 == 0) ? 0 : (3 - length % 3); 57 | let bytes = new Uint8Array(length + added); 58 | bytes.set(originalBytes); 59 | 60 | let output = ""; 61 | for (let i = 0; i < bytes.length; i += 3) { 62 | // Convert 3 8-bit bytes into 4 6-bit indices and get a character from 63 | // the master list based on each 6-bit index 64 | // 3 x 8-bit: |------ --|---- ----|-- ------| 65 | // => 4 x 6-bit: |------|-- ----|---- --|------| 66 | 67 | // Get the first 6 bits of the first byte 68 | output += _a[ bytes[i] >>> 2 ]; 69 | // Merge the end 2 bits of the first byte with the first 4 of the second 70 | output += _a[ ((bytes[i] & 0x3) << 4) | (bytes[i + 1] >>> 4) ]; 71 | // Merge the end 4 bits of the second byte with the first 2 of the third 72 | output += _a[ ((bytes[i + 1] & 0xF) << 2) | (bytes[i + 2] >>> 6) ]; 73 | // Get the last 6 bits of the third byte 74 | output += _a[ bytes[i + 2] & 0x3F ]; 75 | } 76 | 77 | // Turn the final "A" characters into "=" depending on necessary padding 78 | if (added > 0) { 79 | output = output.slice(0, -added) + ("=".repeat(added)); 80 | } 81 | 82 | return output; 83 | }, 84 | 85 | 86 | // Takes a Base64 encoded string and returns a decoded Uint8Array. Throws 87 | // an error if the input string does not appear to be a valid base64 88 | // encoding. Attempts to add padding to un-padded base64 strings. 89 | base64ToBinary: function(s) { 90 | let bytes = []; 91 | 92 | // Base64 strings have at most 2 padding characters to make their length 93 | // a multiple of 4, so they could be missing up to 2 characters and still 94 | // be valid. But if 3 padding characters would be needed, the input 95 | // cannot be valid. Try and add padding characters if necessary/possible. 96 | if (s.length % 4 == 1) { 97 | throw "Invalid base64 input"; 98 | } else if (s.length % 4 != 0) { 99 | s += "=".repeat(4 - (s.length % 4)); 100 | } 101 | 102 | for (let i = 0; i <= (s.length - 4); i += 4) { 103 | // Check that each character in this group of 4 is valid 104 | for (let j = 0; j < 4; j++) { 105 | if (s[i + j] != "=" && !(s[i + j] in _aRev)) { 106 | throw "Invalid base64 input"; 107 | } else if (s[i + j] == "=" && Math.abs(s.length - (i + j)) > 2) { 108 | throw "Invalid base64 input"; 109 | } 110 | } 111 | 112 | // Convert 4 6-bit indices into 3 8-bit bytes by finding the index of 113 | // each 6-bit character in the master list and combining 114 | // 4 x 6-bit: |------|-- ----|---- --|------| 115 | // => 3 x 8-bit: |------ --|---- ----|-- ------| 116 | 117 | // Get all 6 bits of the first byte and first 2 bits of the second byte 118 | bytes.push((_aRev[s[i]] << 2) | (_aRev[s[i + 1]] >>> 4)); 119 | if (s[i + 2] != "=") { 120 | // If not padding, merge end 4 bits of the second byte and first 4 of 121 | // the third 122 | bytes.push(((_aRev[s[i + 1]] & 0xF) << 4) | (_aRev[s[i + 2]] >>> 2)); 123 | } 124 | if (s[i + 3] != "=") { 125 | // If not padding, take the last 2 bits of the third byte and all 6 of 126 | // the fourth. Note that if the fourth byte is padding, then certainly 127 | // the third byte is, so we only have to check the fourth 128 | bytes.push(((_aRev[s[i + 2]] & 0x3) << 6) | _aRev[s[i + 3]]); 129 | } 130 | } 131 | 132 | return new Uint8Array(bytes); 133 | } 134 | 135 | } 136 | })(); 137 | -------------------------------------------------------------------------------- /create/create.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by Jacob Strieb 3 | * May 2020 4 | */ 5 | 6 | 7 | 8 | /******************************************************************************* 9 | * Helper Functions 10 | ******************************************************************************/ 11 | 12 | // Highlight the text in an input with a given id 13 | function highlight(id) { 14 | let output = document.querySelector("#" + id); 15 | output.focus(); 16 | output.select() 17 | output.setSelectionRange(0, output.value.length + 1); 18 | return output; 19 | } 20 | 21 | 22 | // Validate all inputs, and display an error if necessary 23 | function validateInputs() { 24 | var inputs = document.querySelectorAll(".form .labeled-input input"); 25 | for (let i = 0; i < inputs.length; i++) { 26 | let input = inputs[i]; 27 | input.reportValidity = input.reportValidity || (() => true); 28 | if (!input.reportValidity()) { 29 | return false; 30 | } 31 | } 32 | 33 | // Extra check for older browsers for URL input. Not sure if necessary, since 34 | // older browsers without built-in HTML5 validation may fail elsewhere. 35 | const url = document.querySelector("#url"); 36 | let urlObj; 37 | try { 38 | urlObj = new URL(url.value); 39 | } catch { 40 | if (!("reportValidity" in url)) { 41 | alert("URL invalid. Make sure to include 'http://' or 'https://' at the " 42 | + "beginning."); 43 | } 44 | return false; 45 | } 46 | 47 | // Check for non-HTTP protocols; blocks them to prevent XSS attacks. Also 48 | // allow magnet links for password-protected torrents. 49 | if (!(urlObj.protocol == "http:" 50 | || urlObj.protocol == "https:" 51 | || urlObj.protocol == "magnet:")) { 52 | url.setCustomValidity("The link uses a non-hypertext protocol, which is " 53 | + "not allowed. The URL begins with " + urlObj.protocol + " and may be " 54 | + "malicious."); 55 | url.reportValidity(); 56 | return false; 57 | } 58 | 59 | return true; 60 | } 61 | 62 | 63 | // Perform encryption based on parameters, and return a base64-encoded JSON 64 | // object containing all of the relevant data for use in the URL fragment. 65 | async function generateFragment(url, passwd, hint, useRandomSalt, useRandomIv) { 66 | const api = apiVersions[LATEST_API_VERSION]; 67 | 68 | const salt = useRandomSalt ? await api.randomSalt() : null; 69 | const iv = useRandomIv ? await api.randomIv() : null; 70 | const encrypted = await api.encrypt(url, passwd, salt, iv); 71 | const output = { 72 | v: LATEST_API_VERSION, 73 | e: b64.binaryToBase64(new Uint8Array(encrypted)) 74 | } 75 | 76 | // Add the hint if there is one 77 | if (hint != "") { 78 | output["h"] = hint; 79 | } 80 | 81 | // Add the salt and/or initialization vector if randomly generated 82 | if (useRandomSalt) { 83 | output["s"] = b64.binaryToBase64(salt); 84 | } 85 | if (useRandomIv) { 86 | output["i"] = b64.binaryToBase64(iv); 87 | } 88 | 89 | // Return the base64-encoded output 90 | return b64.encode(JSON.stringify(output)); 91 | } 92 | 93 | 94 | 95 | /******************************************************************************* 96 | * Main UI Functions 97 | ******************************************************************************/ 98 | 99 | // Activated when the "Encrypt" button is pressed 100 | async function onEncrypt() { 101 | if (!validateInputs()) { 102 | return; 103 | } 104 | 105 | // Check that password is successfully confirmed 106 | const password = document.querySelector("#password").value; 107 | const confirmPassword = document.querySelector("#confirm-password") 108 | const confirmation = confirmPassword.value; 109 | if (password != confirmation) { 110 | confirmPassword.setCustomValidity("Passwords do not match"); 111 | confirmPassword.reportValidity(); 112 | return; 113 | } 114 | 115 | // Initialize values for encryption 116 | const url = document.querySelector("#url").value; 117 | const useRandomIv = document.querySelector("#iv").checked; 118 | const useRandomSalt = document.querySelector("#salt").checked; 119 | 120 | const hint = document.querySelector("#hint").value 121 | 122 | const encrypted = await generateFragment(url, password, hint, useRandomSalt, 123 | useRandomIv); 124 | const output = `https://jstrieb.github.io/link-lock/#${encrypted}`; 125 | 126 | document.querySelector("#output").value = output; 127 | highlight("output"); 128 | 129 | // Adjust "Hidden Bookmark" link 130 | document.querySelector("#bookmark").href = `https://jstrieb.github.io/link-lock/hidden/#${encrypted}`; 131 | 132 | // Adjust "Open in New Tab" link 133 | document.querySelector("#open").href = output; 134 | 135 | // Adjust "Get TinyURL" button 136 | // document.querySelector("#tinyurl").value = output; 137 | 138 | // Scroll to the bottom so the user sees where the bookmark was created 139 | window.scrollTo({ 140 | top: document.body.scrollHeight, 141 | behavior: "smooth", 142 | }); 143 | } 144 | 145 | 146 | // Activated when the "Copy" button is pressed 147 | function onCopy(id) { 148 | // Select and copy 149 | const output = highlight(id); 150 | document.execCommand("copy"); 151 | 152 | // Alert the user that the text was successfully copied 153 | const alertArea = document.querySelector(".alert"); 154 | alertArea.innerText = `Copied ${output.value.length} characters`; 155 | alertArea.style.opacity = "1"; 156 | setTimeout(() => { alertArea.style.opacity = 0; }, 3000); 157 | 158 | // Deselect 159 | output.selectionEnd = output.selectionStart; 160 | output.blur(); 161 | } 162 | 163 | 164 | // Activated when a user tries to disable randomization of the IV -- adds a 165 | // scary warning that will frighten off anyone with common sense, unless they 166 | // desperately need the URL to be a few characters shorter. 167 | function onIvCheck(checkbox) { 168 | if (!checkbox.checked) { 169 | checkbox.checked = !confirm("Please only disable initialization vector " 170 | + "randomization if you know what you are doing. Disabling this is " 171 | + "detrimental to the security of your encrypted link, and it only " 172 | + "saves 20-25 characters in the URL length.\n\nPress \"Cancel\" unless " 173 | + "you are very sure you know what you are doing."); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /hidden/README.md: -------------------------------------------------------------------------------- 1 | # Bookmark Knocking: Hidden Bookmarks Without a Browser Extension 2 | 3 | Click special bookmarks in the right order to open a hidden link. 4 | 5 | - [Try the demo](https://jstrieb.github.io/projects/hidden-bookmarks/#demo) 6 | 7 | --- 8 | 9 | # Introduction 10 | 11 | Imagine that you want to propose to your partner, but they sometimes use your 12 | computer. You don't want them to see that you are bookmarking wedding rings. 13 | What do you do? 14 | 15 | Alternatively, imagine you live with someone abusive. You decide to get help, 16 | so you look for resources on the Internet. There are helpful links, but you 17 | know if you bookmark them and your abuser goes through your computer, they may 18 | find them. You can't install a hidden bookmark extension either, because they 19 | could just as easily notice that. What do you do? Unfortunately, this is a 20 | [realistic scenario for many 21 | people](https://www.nytimes.com/wirecutter/blog/domestic-abusers-can-control-your-devices-heres-how-to-fight-back/). 22 | 23 | Almost a year ago, I created [Link Lock](https://jstrieb.github.io/link-lock) 24 | -- a tool to enable anyone to securely password-protect URLs. But adding a 25 | password to links isn't always enough. 26 | 27 | Link Lock relies on strong cryptography for security, but sometimes a layer of 28 | obscurity is a practical necessity. In other words, there are some situations 29 | where a bookmark that asks for a password is too suspicious to be useful, even 30 | if the password protection is secure. 31 | 32 | Bookmark knocking is a novel technique to address this problem. It enables 33 | users to hide bookmarks using features already built into every web browser. 34 | There are two versions available: 35 | 36 | - [A stable, simplified version integrated directly into Link 37 | Lock](https://jstrieb.github.io/link-lock/hidden/) 38 | - [An experimental 39 | version](https://jstrieb.github.io/projects/hidden-bookmarks/#demo), designed 40 | to test the limits of the idea 41 | 42 | 43 | # How It Works 44 | 45 | Bookmark knocking is similar to [port 46 | knocking](https://en.wikipedia.org/wiki/Port_knocking), for which it was named. 47 | A user who wants access to a hidden link must know to click the right bookmarks 48 | in the right "knock sequence." If they do this, they will be redirected to the 49 | hidden page. 50 | 51 | The concept relies on storing encrypted data about the hidden link in the [URL 52 | fragment](https://en.wikipedia.org/wiki/URI_fragment) or "hash." This is the 53 | part of the URL that comes after a `#`, and typically takes a user to some spot 54 | in the middle of the page. 55 | 56 | In this case, the hash contains a 57 | [base64](https://en.wikipedia.org/wiki/Base64)-encoded 58 | [JSON](https://en.wikipedia.org/wiki/JSON) object. The object consists of the 59 | [AES](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard)-encrypted 60 | secret URL and the currently-attempted knock sequence. The knock sequence 61 | attempt is stored as a string of characters, and is used as a passphrase to 62 | try decrypting the secret link after each knock. 63 | 64 | When one of the special knock sequence bookmarks is clicked, it runs JavaScript 65 | to check if the current URL fragment is base64-encoded JSON with the required 66 | information. If not, it redirects to the user-specified decoy bookmark link. If 67 | so, it adds some static characters to the current passphrase attempt string and 68 | tries to decrypt the hidden link using the newly-modified passphrase. 69 | 70 | If decryption succeeds, it redirects to the now-decrypted, no-longer-hidden 71 | link. On the other hand, if this attempt fails, it redirects to the bookmark 72 | link that it normally would, but with a URL fragment containing updated 73 | information about the latest attempt. Then the user can perform the next knock 74 | in the sequence, and the process repeats. 75 | 76 | Since it is perfectly valid to have an arbitrary hash at the end of a typical 77 | URL, the bookmark behaves normally if the knock sequence is incorrect or 78 | incomplete. The only distinguishing feature of the decoy bookmark URLs is the 79 | presence of a long, nonsensical fragment, which wouldn't alarm most people. 80 | 81 | ## Link Lock Version 82 | 83 | The simplified version of bookmark knocking built into Link Lock only supports 84 | two knocks. There is one universal second knock for any valid first knock. Then 85 | the hidden link prompts for a password. This two-knock version provides a 86 | practical level of privacy, without compromising on usability or security. 87 | 88 | 89 | 95 | 96 | 97 | 98 | # Who It Is For 99 | 100 | Software security claims are only valid relative to a well-defined threat 101 | model. In this case, the software aims to be secure against family and friends, 102 | not agencies. 103 | 104 | In other words, links protected with bookmark knocking (as implemented here) 105 | will be difficult to notice for most people, let alone crack. But the 106 | protection *can* be noticed by an astute observer, and *can* be broken by a 107 | determined adversary. (The keyspace is extremely small. Assume any attacker 108 | with all of the bookmarks in the knock sequence and the ability to brute force 109 | AES-GCM-encrypted data will successfully uncover your hidden link. On the other 110 | hand, if you hide a Link Lock URL, the hidden link will be securely 111 | password-protected.) 112 | 113 | Despite shortcomings, bookmark knocking is still a useful part of 114 | defense-in-depth. For more serious security, use the version built into [Link 115 | Lock](https://jstrieb.github.io/link-lock/). 116 | 117 | **Don't forget to use private browsing or incognito mode when accessing hidden 118 | links, otherwise the secret links are stored in your browser history, and the 119 | protection is worthless!** 120 | 121 | Example use cases: 122 | 123 | - Hide private links from other users of a shared computer 124 | - Prevent embarrassing bookmarks from being accidentally opened during a 125 | live-stream, video call, or demonstration 126 | - Access a secret link without typing in a password (if there is concern about 127 | keyloggers or other [stalkerware](https://en.wikipedia.org/wiki/Stalkerware)) 128 | - Create a fun riddle or prank for the owner of a computer you gain access to 129 | - Discreetly save personal bookmarks to a work computer 130 | 131 | 132 | 133 | # Known Issues 134 | 135 | If you have ideas for how to address the following problems, or want to discuss 136 | others, please [open an issue on 137 | GitHub](https://github.com/jstrieb/link-lock/issues/new) or use my [contact 138 | form](https://jstrieb.github.io/about#contact). 139 | 140 | - Generated bookmarks are prefixed with `javascript:` and therefore cannot have 141 | favicons. As such, they're not perfectly identical to a regular bookmark for 142 | the same site. 143 | - Websites that modify the URL fragment will screw up the bookmark knocking. 144 | These sites should not be used for steps in the knock sequence. Some examples 145 | include Gmail and Telegram. 146 | - Only tested with desktop Firefox and Chrome. Not tested with Safari, Edge, or 147 | on mobile devices. 148 | - Despite spending hours revising the instructions for the [Link Lock hidden 149 | bookmarks](https://jstrieb.github.io/link-lock/hidden/) page, it is still far 150 | from perfect. Making this idea easy to use and understand is very difficult. 151 | 152 | 153 | 154 | # For Abuse Victims 155 | 156 | This technology is designed to be helpful for anyone who needs more privacy 157 | than they feel they have, but it cannot guarantee anything. You are the expert 158 | in your own situation, and you need to judge if it is appropriate to use this 159 | software. If you are in a dangerous situation, please seek help. 160 | 161 | From a [New York Times 162 | Article](https://www.nytimes.com/wirecutter/blog/domestic-abusers-can-control-your-devices-heres-how-to-fight-back/) 163 | on technology and domestic abuse: 164 | 165 | > If you are in immediate danger, call 911. 166 | > 167 | > If your calls are being tracked, call your local services hotline, like 211 168 | > or 311, and ask to be transferred to a local resource center. 169 | > 170 | > If you or someone you know is in an abusive relationship or has been sexually 171 | > assaulted, call the [National Sexual Assault 172 | > Hotline](https://www.rainn.org/get-help/national-sexual-assault-hotline) at 173 | > 800-656-HOPE or the [National Domestic Violence 174 | > Hotline](https://www.thehotline.org/) at 800-799-SAFE (you can also [chat 175 | > live with an advocate at 176 | > NDVH](https://www.thehotline.org/what-is-live-chat/), or text LOVEIS to 177 | > 22522). 178 | -------------------------------------------------------------------------------- /hidden/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Create Hidden Bookmarks 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | View on GitHub 28 | 29 | 30 | 31 | 40 | 41 | 42 | 47 | 48 |

Create Hidden Bookmarks

49 |

It is possible to protect bookmarks with a password using Link Lock, but a link that needs a password may appear suspicious to someone else seeing it. Hidden bookmarks solve this problem.

50 |

Hidden bookmarks are disguised to be identical to normal bookmarks, with one exception: clicking them in the right order will open a hidden link. To open the hidden link, click the disguised bookmark first, and then click the decrypt bookmark next. The same decrypt bookmark works for all disguised bookmarks.

51 |

Read more about how hidden bookmarks work here.

52 | 53 |

Here is how to create hidden bookmarks:

54 |
    55 |
  1. 56 | Add a password to the hidden link if you have not done so already. 57 |
  2. 58 |
  3. 59 | Drag the "decrypt" bookmark below to your bookmarks bar. 60 | 61 | 62 | 63 | 73 |
    74 | advanced 75 |
    76 | 79 | 80 |
    81 | 82 | 83 |
    84 |
    85 |
    86 | 87 |

    Decrypt

    193 |
  4. 194 |
  5. It may be a good idea to rename the decrypt bookmark to "Gmail" by right clicking, and either clicking "Edit" or "Properties."
  6. 195 |
  7. 196 | Fill in the hidden URL below (if it is not already filled in). Then, fill in the disguised bookmark name and link. 197 | 198 |
  8. 199 |
  9. Press the button to create the bookmark. Once created, drag the disguised bookmark to your bookmarks bar.
  10. 200 |
201 | 202 |
203 | 204 |
205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 |

INVISIBLE

214 |
215 | 216 |
217 | 218 | 219 | 224 |
225 | 226 | 227 | No Disguised Bookmark Created Yet 228 |

Drag the disguised bookmark above to the bookmarks bar.

229 |

To access the hidden link, click the disguised bookmark, then the decrypt bookmark (which may have been renamed to "Gmail").

230 |
231 | 232 | 233 | 237 | 238 | 239 | 240 | 241 | -------------------------------------------------------------------------------- /create/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Link Lock - Password-protect links 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | View on GitHub 48 | 49 | 50 | 51 | 60 | 61 | 62 | 66 | 67 | 68 |

Link Lock

69 |
70 |

Link Lock is a tool for adding a password to a link; in other words, for encrypting and decrypting URLs. When a user visits an encrypted URL, they will be prompted for a password. If the password is correct, Link Lock sends them to the hidden website. Otherwise, an error is displayed. Users can also add hints to remind them of the password.

71 |

Each encrypted URL is stored entirely within the link generated by this application. As a result, users control all the data they create with Link Lock. Nothing is ever stored on a server, and there are no cookies, tracking, or signups. View on GitHub for more information, including translated versions.

72 |

Link Lock has many uses, for example:

73 | 81 |
82 | 83 |
84 | 85 | 86 |
87 |
88 | 89 | 90 |
91 |
92 | 93 | 94 |
95 |
96 |
97 | 98 | 99 |
100 |
101 | 102 | 103 |
104 |
105 | 106 | 107 |
108 | advanced 109 |
110 |
111 | 112 | 113 |
114 |
115 | 116 | 117 |
118 |
119 |
120 | 121 |
122 | 123 |
124 | 125 | 126 |
127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 141 |

Copied

142 |
143 | 144 | 145 | 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /corner-ribbon-minified.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Link Lock 2 | 3 | [Password-protect URLs using AES in the 4 | browser.](https://jstrieb.github.io/link-lock) 5 | 6 | Link Lock now supports secure, hidden bookmarks via bookmark knocking! Read 7 | more [here](https://jstrieb.github.io/projects/hidden-bookmarks). 8 | 9 | 10 | 11 | ## About 12 | 13 | Link Lock is a tool for encrypting and decrypting URLs. When a user visits an 14 | encrypted URL, they will be prompted for a password. If the password is 15 | correct, Link Lock retrieves the original URL and then redirects there. 16 | Otherwise, an error is displayed. Users can also add hints to display near the 17 | password prompt. 18 | 19 | Each encrypted URL is stored entirely within the link generated by the 20 | application. As a result, users control all the data they create with Link 21 | Lock. Nothing is ever stored on a server, and there are no cookies, tracking, 22 | or signups. 23 | 24 | Link Lock has many uses: 25 | 26 | - Store private bookmarks on a shared computer 27 | - Encrypt entire web pages (via [URL 28 | Pages](https://github.com/jstrieb/urlpages)) 29 | - Send sensitive links over public or insecure channels (e.g., posting links 30 | to a public website that require a password to access) 31 | - Implement simple CAPTCHAs – particularly effective against basic web scrapers 32 | that do not respect `robots.txt` 33 | - Add a password to shared Dropbox or Google Drive links 34 | - Share password-protected magnet links and torrents 35 | - [Evade censorship](#evading-censorship) 36 | 37 | Link Lock uses AES in GCM mode to securely encrypt passwords, and PBKDF2 and 38 | salted SHA-256 (100,000 iterations) for secure key derivation. Encryption, 39 | decryption, and key derivation are all performed by the [`SubtleCrypto` 40 | API](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto). The 41 | initialization vector is randomized by default, but the salt is not. 42 | Randomization of both the initialization vector and salt can be enabled or 43 | disabled by the user via "advanced options." The salt and initialization vector 44 | are sent with the encrypted data if they are randomly generated. The API is 45 | versioned such that old encrypted links will always work, even if later 46 | versions of Link Lock are updated to be more secure. Please read the code 47 | ([`api.js`](https://github.com/jstrieb/link-lock/blob/master/api.js) in 48 | particular) for more information. 49 | 50 | Read the Hacker News discussion [here](https://news.ycombinator.com/item?id=23242290). 51 | 52 | Also [discussed on 53 | r/netsec](https://www.reddit.com/r/netsec/comments/i3n4sm/link_lock_password_protect_urls_using_aes_in_the/) 54 | and [discussed on 55 | r/programming](https://www.reddit.com/r/programming/comments/i5kpjx/link_lock_is_a_tool_for_encrypting_and_decrypting/). 56 | 57 | 58 | 59 | ## Examples 60 | 61 | - [Regular link encryption](https://jstrieb.github.io/link-lock/#eyJ2IjoiMC4wLjEiLCJlIjoiRkVJR0FzdE53NXFzM1N1N0pVQUtlOEJpWWtYWERaU012WmFDdWxZYWo2eFJzY2lDRGJXd3VZRmFkdVpqbEN4UDN4S0lWQXRoMmJHcjhHWUciLCJoIjoiXG5QYXNzd29yZDogaGFja2VybmV3cyIsImkiOiJBOWJMZkFKbXJ1RFplWktJIn0=) - Password: hackernews 62 | - [Encrypt entire 63 | pages](https://jstrieb.github.io/link-lock/#eyJ2IjoiMC4wLjEiLCJlIjoiWWhjbG0xeE9uZTJWU2tvc3N1WERwKytyN1lscW1nMVNNemRoSUVER2xVZVBTUFZ3MFA3WTVwQXdnVFVKZkt4WHJ4Nlg1KytCU09RNlVTTlI3M244VEdTeWJGMmJFTG5wc0x6WVRtZnQ3aDFZSzJ5VW16TEpBTk5VOThqZFMvTVFNUG93cWdoRjVUVnYyRWF1VkVHVVlJeE5iT3BtaldCNWJyMWpXemMyakJTNUxZVGVSajNTbVI5UWNwWlRWWmVrbit4Rzd3VzNIcEttRTdVRWNtbkhZS2dydGVmaHp5eTJGNVd6N1NKSm55OTJPWnJUOEFHUE9XY3JUbmxYV0NsTDB5QjVsQmZnUTJkcHk4Y3RmMHNvdVlvb1l2LzQ1U3krZUNtdHl2WkVDd25IeUhwUForamxsaDhuNUV5U2N1ZVRWTmRtRmlmOFBhM0FtdUpQOTdTYWZXbzNwbUo4cU40UFYvMllQbHlwSGFtTmI1dnBBQkc2cU1yUWlLMVp3WHBUSnF4OG9NNFdVVGh3L3B5S0QzOWRNNml2RlNzQzVRUWpaVHl0ODlSNDNVOVdkRDVMWHprdlZ1bVpNSmM2WDExTkI4V0ZSKzdyOGVvVU8wR21rRkxTU0JlaDJickt3bzkwWjRlZkJHTkZiYWE2dU9SWnQzSm1YU0NSSGZyclVRQ053cU96R2pCKzBYZHJFeC9NbHd3QkFKNTIvY0EraW9IUDk5RkszUDN1MlN6Sk1uQzVVSFg1NGNDd1Z2dWdiMzAvUmNsMjZvZzFxUDU0NWJlMGFiak9wYnZ5aFp6RjhkdDNUUjJFLzBMY2dUQUg4dE5wSVAyYzJoM2d4NlJEQUNTZ25LRzlteW4xdFU4Y0IwbWMrd1NPdkxIRlVXVXhIYnpGSkR0aS9MSDg1RDFvdVRNWTFjM3BsSSsxRFFROG5lbjVrR2hmRUhELzdsSFhIY1ZWTHNCbi9HOTFJZU02T2pTeS9aZFcySGZ4d050VzR2WEE3em1FdjhYRDNHL3M2ZTVqdVdQWjV3ck5JWFdzcDVROHdUSlI3U2JQUi94VDNwUUZncW9LaDF2OXVEWGZBaE5xYStXaElzNTlaR1UzdFlkRVFOZEVLdGpIcnF1bzJkcVpuNnB4eTU1ZDJiOVBrcFRLNGh5TEtDOEc1TmN3TEE3dUIzYTNlNlZ2NjVVVHcrdS9oWTBoMy9Nb3ZJaERmT3k2aGZiN2FQaEIyMStxSGZSeWt2VlFPUFZrbE41ak5EK1hKZURialgvd0NUWXJJVm0yOFZkTHppZURob2ZpRGpJRjdyakFQNlF6dWJjaGJYRGFtbFZQWUhOaGVNMWdTeGROSGw5a1lRVE5kbjA1WlcvbVhXNkQwbHk0VkwrOHRwZzdxQjU2YTRyL3lIWHA0Q0tSUkdIaEVWQUptbmh2ZnBaWE11QWdneGVoSkRibVdVKy9VMUgwM2JicUZub2h5R0VGRUxQV2JjZ05kdDJwWU1Cdy81TVNqSkdWWWRPQk5nTUsxbHA2ZVRxRGhwTVdJT2E4a1dSYWx3RzV1bDhuQjhnUVBkcXBCYVdxc3I3V242SVZoZHdLc0FvTGtsdTlnL0JoelNlZEQxRjcyblprN2tSS2l3a3BJbVhOeW9TQk1SSFJSMURjSm9qdU1ZVWlrZ2JxM0dpR2ZqNmMwTTBlU2lyMlhJRnRCTzd2VkJyRmpZL1pvVnJBQ1kwTzJ2UVlGcHovaEprNElKN0daOUpmc3U4ajl5Umc5S3IrNFU3MFhoZHRLY1VYeEtrbCt1VDBtN1owb2puR0xWOGRtampzTVdna3ZhV0FYNkJpK3cycVJKYnVYRW5yUEN5dUZGODhiZ2k3UDNYUVhOMHZTY3h2Uk4wVktKQ1MvR2RVWTJsZ0lDSXVBWFlUVE9KTGNsRkJPQWxialRmZThoTG5saTkzQm4xcnZOamhnM0Y2UkJ2N3NQOTlzODlGT3pwcEZHeHVKS1RhNEg4Y2NSRmxMWDBWbE9kR0RhNWM0NGVTdzh5dCsxWWJndDlvMlExcWNSYVZsaVdadSs5VjdxM1pqcWIxcDdKb2FUN0pDQ1U2ZXR6b0dJWjBQT1JqL3pVNUlVQkRjYXdHZWszZ0djSDBLdDcxa1NSN0F2TWRYeTR3WVI4ZmdTTlpoR3gwSTZYczZ5Vy9oWFB1WERPRjNHTVBTRFFmNGNhUjBuc3pmYTl3MXdGMzVSYktodEVkZnIwU0NLQzhIRXFzNWdsQ0M4RmIxN04wbGtBVlFwSWFRRGJrN254TjVINEFhQ3RKbU5JNHFYUDhocUV6aVhySGhhZWNzNkVBUDBvdjg2cWp4dz09IiwiaCI6InVybHBhZ2U1IiwiaSI6InJNZ2xiSEpzK3pSL2dteFAifQ==) - Password: urlpage5 64 | - [Implement a simple 65 | CAPTCHA](https://jstrieb.github.io/link-lock/#eyJ2IjoiMC4wLjEiLCJlIjoiZEx3Yi9CNitlK0ZjM1B3ZURrbUY2NjdQWFlIV1dsS3dpclhvZmkvRXBFTXU0ZERlVkJuSmUrN1loS2JxQ3RrPSIsImgiOiIxICsgMSA9ID8iLCJpIjoiRDJYd1MyK1EzaHpuUDV1NyJ9) 66 | - [Emoji 67 | support](https://jstrieb.github.io/link-lock/#eyJ2IjoiMC4wLjEiLCJlIjoiU1ZBemc0NUVoeXJMR1hXYmRUMXpLSFFIa0hiR2F3SzlMaWZzWW5SL0ZiaGp1cnZqMGg5VTE0bG9kVGs3S3B0TjdhcjZ2T3FvRjJLNkxMcDByL05PZE5nUTJ3UlhVOWM2RmFJdXNGajdrNkFkTC82OVJ6dmlFV2R0dWVacFM1dS9SN2w4L3Mzc1pMTVJNeHdhTVhVenYxTjZUVkdWTGloaXc3ZXlGY093Nkp2ZVN3aGl0OW9XWW84Yk9CMkpkTTF4ZnFRSGExbEoiLCJoIjoi8J+lkSIsImkiOiI5L3pmdHFmeHdoWFh4bDc4In0=) - Password: avocado 68 | - [Riddles in the dark](https://jstrieb.github.io/link-lock/#eyJ2IjoiMC4wLjEiLCJlIjoiYkV5TzZDZ0VwVXZOU2xucWZiQlo3YUdYa1pSL1lub00vekJNaTBMS1VQRXVicktJQTBhS00zNkNEK0tDWXYxd3l0L2dxWTFMS0NPQnFLTldSSEpsYXVodEQ4L0dKVkU3eTFnb2JZalRmcXFtWmpRQTlVMmNhL3dJb2JvMk5DdENnWTZYMHE5MHJzZDk1L0Z6K2orNGlYSHJBa2htN01kL2pJRnNIeDIwZlNVTEFadUpyNjF6TTRucS9nQTZNcW9OS1VKaUxpSWdEakdUd0dhRmdER29OdHJ5Kzd1cUtVbW1BbWQvbWlISERqRDY3bjRtSDA5eWtndlIyVnkzanRsdU44S0puMWdkYWlTeGQvWVhXcUR2WUhrMWIzOVFlSnlIYUV5Q3ZlQkJoQ1RzZDVJbG1obU45WjB0UTdoYUpGVUl1R2hxOTNrOC82MDhpbUxKR0p0M1VNbHRHcEZJck9wUGJuSTlSVkg4WGhwVFh5aEc0Q0ZWajNNU1FGWHdLOXRkc1M4MGF0VGc1MXliZHBEcVIwV1dtWHJBN0hIQ1RCV253WEQ4SGR3SWZidkNmR2s2aW5ZajRHaHpjYUJ4UmV2c08xaGs0M1FmblQya0JHSHhBd0JqQkZTeVlrWCtldGlINHhVVFY3L295ZWtINWxzMmFSczZxc290RDNhS25jWDR3dm1VRUZNKzd3d3Z0UTlhUSt0NFprUWNyWmxGOFBUSVNRRmN3M0ZDMG5zREt1b2lHWWx1S044SGk1dW1QVjZvSC91ckdHSFJBd0VmWDg3VDNubzFBOHRJaEFhYURjSnBpQ2xvZERId3FYOWZmZC9US2JWVUd3a0cxMHdERGN1QUMrR3lJZUVNWExpR2VtNC9DSWNINnRRNTV1UGRZTmZ2aTVuNkU3UHZydFhOSkh5a21jdHpSUVZlSGlOeXp4TDZNY1BSSmZqLy9xZ0dWSWQ0M3Z6aFJBZDFzZHpYa1ZtREhJNFpneDBrQ3dxd05FUllLU3JTNGIwdmV3c3dOQjRVa2lIUFMwMDFpUjVCNG1hUjAvMkhhSFJnWjNMT0RFU1U4djdVNVhrUHllNC85WTZmY3BNeDdWRXg2OUYrem1GL1JsNml3bU1FYk55Ry9YQlU3bERGYXB3Uk1HUmExLzNSTWRObzFoVm5oQ08rNzRMNkxRQlNBSk04VldiZUE2bXVydG1vVHpxb2gvaWtqckxHKzFGUUVvM1lSMnhwQ1VlWVhxOXFSK2YwRGFCM3d4NFk4S0IrcmdUelhLc1JDZXBCTHRjVkhtcFV5UW4rdWswVitDZ0RCOXlPSkJFSXpYSmNnUWxHVlMyOGF4RjFmTnR4UTZkbzdiTU5xbFlqN1RvVzhaQ1NNU0JOVFU5cU92dlNESFBxTFZZOXEzYlhhc3gvZDVwRDBiTzdHTzVsdjlubm91MTZHNUpVTFRlM2IrN0I2azFpZFE3akd0VDFmOTNhUEg0bzZodWlLQXBZdVZQOVJjVHBHVlVyd3dhMzlPcFF4eFNJS2xxeW53d0xwZGxOMjJ0NUJIRm92c1IyUkZCeDhXTFA1a0tuNG9HdTJJcDVBVThDeGs4MG5GZ2VjUGIzS3RuL1lteHJqTVNVVkxmSXMyVUxmbHhqbkE5Q01aWi9ZdG0zZXpFc0VveU1wM3FZWnN1SGh4Rld2ajZXalFkUXdxRXlHY2Z6ZG94dDFvTXh1WVB3ZjF3N3JxWlR6eDVwVkptRGZ1V1VDUUxub2c5c0RqWmRKSHhZVTRadCt2NEYrRDJXdlZ1c0FZSzNJRGJZSnQzOWxSRUxXcUp4dkpaa2pHeXdSaWo1OGsvUzZtTmg0SnJaTk5hNmZFT08rcjU2U2RMcHI1dmh3enorbjFURExiWWZILzdsU0xvc0VaZk4vMFQyOFBrdlJEMlcrMnVvWm5QaVFRV2FnQXdIcmRCc0UrUjdEbFNsdnJWNmJtN0tuSlBWc2NqbXRVVlc3SXBDZ1NNMWh4QWhSZm5hRDJucGdRMmVocUhJZXFlZkpFc281WE1pVDNiWVUwRS92WVdSaHROMTJWdkwxa0k2ejA1UlR3PT0iLCJoIjoiVm9pY2VsZXNzIGl0IGNyaWVzLFxuV2luZ2xlc3MgZmx1dHRlcnMsXG5Ub290aGxlc3MgYml0ZXMsXG5Nb3V0aGxlc3MgbXV0dGVycy4iLCJpIjoiWW91TVFyMmJXRHdGMW1BVSJ9) - The password is a single, lowercase word 69 | - [Share 70 | torrents](https://jstrieb.github.io/link-lock/#eyJ2IjoiMC4wLjEiLCJlIjoieVJqZnVGdlJETGFTdk4vRVYzUlg3OG9GZHRlWW81U04wcFlvSkFScFRaeXFwZTVoV1lESjFBeDVWRUswMDBNUlQ2ZVAwZ2tCTmlyaVdrYnNsVFdrZTNtNVVOVnoxSW43Z3BST1hQZDhsVmVDTkpJZi81Wm1PWFdzSDZ6dVJmdkVrald0UTRndkZBUE9VSm9id00rdnhtWGtuZW5TZ0pHeW9mMjg3L01pTERDN085NFoxTUwrMzlaNUkwdCtsaW1CaDFaNElWZ1p1QkpQUURvM2NodWZXemdTNU05Zk1FOFlxNXVUV1ZoZjVLV2VaTUR1Q0VWSmN2TjRXbDByZHl6MFpBPT0iLCJoIjoiXG5QYXNzd29yZDogdG9ycmVudGluZ19pcy1sZWdhbCEiLCJpIjoiUlIvNnJtRFhzb1lGblhiOSJ9) - Password: torrenting_is-legal! 71 | 72 | 73 | 74 | ## Disclaimer 75 | 76 | The code was written to be read. Please read it, especially if you don't trust 77 | me to build a secure encryption application. In particular: 78 | 79 | - Once someone decrypts a link, they can share the original URL as much as they 80 | want. Only share encrypted links with trusted people. 81 | - Most of the encryption/decryption code is based on [MDN 82 | tutorials](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey#PBKDF2_2) 83 | for the `SubtleCrypto` API. 84 | 85 | 86 | 87 | ## Usage 88 | 89 | - Create a locked link here: [https://jstrieb.github.io/link-lock](https://jstrieb.github.io/link-lock). 90 | - Once you have a locked link, create a hidden bookmark here: 91 |