└── README.md /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://github.com/JWally/BrowserPrivateKeyDemo/assets/2482935/b55c569a-b7dc-4731-a11d-d441a92e4297) 2 | 3 | # Secure Private Key Creation and Storage in the Browser 4 | 5 | ## What Is This? 6 | The following code snippet shows how to store a private key that can be _USED_ in your browser, but _CANNOT_ be extracted or stolen. 7 | 8 | ## Why Should I Care? 9 | You (or FireBase, SupaBase, Cognito, etc) can use this approach to make session-token-theft _EXTREMELY_ difficult. 10 | 11 | ## Prove It! 12 | In your browser, open the dev console (`CTRL+SHFT+I`), then copy and paste the following code snippet in: 13 | 14 | ```javascript 15 | /** 16 | * Name of the IndexedDB database. 17 | * @type {string} 18 | */ 19 | const dbName = "CryptoKeys"; 20 | 21 | /** 22 | * Name of the object store within the IndexedDB. 23 | * @type {string} 24 | */ 25 | const storeName = "keys"; 26 | 27 | /** 28 | * Identifier for the key pair stored in the database. 29 | * @type {string} 30 | */ 31 | const keyPairName = "ecdsaKeyPair"; 32 | 33 | /** 34 | * Opens or creates an IndexedDB database and ensures it contains the required object store. 35 | * @returns {Promise} A promise that resolves with the database object on success. 36 | */ 37 | function openDatabase() { 38 | return new Promise((resolve, reject) => { 39 | // Attempt to open the database 40 | const request = indexedDB.open(dbName, 1); 41 | 42 | // Create the store if this is the first time the database is being opened (i.e., on upgrade) 43 | request.onupgradeneeded = function(event) { 44 | const db = event.target.result; 45 | if (!db.objectStoreNames.contains(storeName)) { 46 | db.createObjectStore(storeName); 47 | } 48 | }; 49 | 50 | // Resolve the promise with the database instance on successful opening 51 | request.onsuccess = () => resolve(request.result); 52 | 53 | // Reject the promise with the error on failure 54 | request.onerror = () => reject(request.error); 55 | }); 56 | } 57 | 58 | /** 59 | * Retrieves an existing ECDSA key pair from the database or generates a new one if not found. 60 | * @param {IDBDatabase} db - The database instance. 61 | * @returns {Promise} A promise that resolves with the key pair. 62 | */ 63 | async function getKeyPair(db) { 64 | return new Promise(async (resolve, reject) => { 65 | const transaction = db.transaction([storeName], "readwrite"); 66 | const store = transaction.objectStore(storeName); 67 | const request = store.get(keyPairName); 68 | 69 | request.onsuccess = async (event) => { 70 | if (request.result) { 71 | // Resolve with the found key pair 72 | resolve(request.result); 73 | } else { 74 | // Generate a new key pair if not found 75 | try { 76 | const keyPair = await crypto.subtle.generateKey( 77 | { name: "ECDSA", namedCurve: "P-256" }, 78 | false, // THIS MUST BE FALSE!!! OTHERWISE THE PRIVATE KEY IS EXPOSED!!! 79 | ["sign", "verify"] 80 | ); 81 | 82 | // Save the new key pair in the database 83 | const putTransaction = db.transaction([storeName], "readwrite"); 84 | const putStore = putTransaction.objectStore(storeName); 85 | const putRequest = putStore.put(keyPair, keyPairName); 86 | putRequest.onsuccess = () => resolve(keyPair); 87 | putRequest.onerror = () => reject(putRequest.error); 88 | } catch (error) { 89 | reject(error); 90 | } 91 | } 92 | }; 93 | request.onerror = () => reject(request.error); 94 | }); 95 | } 96 | 97 | /** 98 | * Signs a message using a given ECDSA private key. 99 | * @param {CryptoKey} privateKey - The private key to sign the message with. 100 | * @param {string} message - The message to sign. 101 | * @returns {Promise} The signature as an ArrayBuffer. 102 | */ 103 | async function signMessage(privateKey, message) { 104 | const encoder = new TextEncoder(); 105 | const data = encoder.encode(message); 106 | return crypto.subtle.sign( 107 | { name: "ECDSA", hash: { name: "SHA-256" } }, 108 | privateKey, 109 | data 110 | ); 111 | } 112 | 113 | /** 114 | * Verifies a signature against the given message using an ECDSA public key. 115 | * @param {CryptoKey} publicKey - The public key to verify the signature with. 116 | * @param {ArrayBuffer} signature - The signature to verify. 117 | * @param {string} message - The message that was signed. 118 | * @returns {Promise} A boolean indicating whether the signature is valid. 119 | */ 120 | async function verifySignature(publicKey, signature, message) { 121 | const encoder = new TextEncoder(); 122 | const data = encoder.encode(message); 123 | return crypto.subtle.verify( 124 | { name: "ECDSA", hash: { name: "SHA-256" } }, 125 | publicKey, 126 | signature, 127 | data 128 | ); 129 | } 130 | 131 | /** 132 | * Converts an ArrayBuffer into a base64 encoded string. 133 | * @param {ArrayBuffer} buffer - The ArrayBuffer to convert. 134 | * @returns {string} The base64 encoded string. 135 | */ 136 | function bufferToBase64(buffer) { 137 | return btoa(String.fromCharCode(...new Uint8Array(buffer))); 138 | } 139 | 140 | /** 141 | * Converts a CryptoKey into a PEM-formatted string. 142 | * @param {CryptoKey} key - The CryptoKey to convert. 143 | * @returns {Promise} The PEM-formatted string of the key. 144 | */ 145 | async function exportPublicKey(key) { 146 | // Export the public key in the SPKI (Subject Public Key Info) format 147 | const exported = await crypto.subtle.exportKey('spki', key); 148 | // Convert the exported ArrayBuffer to a Base64 string 149 | const base64 = window.btoa(String.fromCharCode(...new Uint8Array(exported))); 150 | // Format the Base64 string as PEM 151 | return base64; 152 | } 153 | 154 | /** 155 | * Main function that orchestrates the creation or retrieval of a key pair, 156 | * signs a message, exports the public key, and verifies the signature, logging the results to the console. 157 | */ 158 | async function main() { 159 | const db = await openDatabase(); 160 | const keyPair = await getKeyPair(db); 161 | 162 | const message = "base_64_of_jwt"; 163 | const signatureBuffer = await signMessage(keyPair.privateKey, message); 164 | const signatureBase64 = bufferToBase64(signatureBuffer); 165 | 166 | // Export and log the public key in PEM format 167 | const publicKeyPEM = await exportPublicKey(keyPair.publicKey); 168 | 169 | const instructions = ` 170 | ------------------------------------------ 171 | ------------------------------------------ 172 | Below are: 173 | 1. Your public-key (which should be the same after reloading the browser) 174 | 2. A message being signed with your _PRIVATE_KEY_ 175 | 3. The Message's Signature with your _PRIVATE_KEY_ 176 | 4. The Signature's verification with your _public_key_ 177 | 178 | Try Extracting or viewing your private key data!!! 179 | If I did this right...YOU CAN'T! 180 | 181 | ------------------------------------------ 182 | ------------------------------------------ 183 | `; 184 | console.log(instructions); 185 | console.log(`Message: ${message}`); 186 | console.log(`Public Key: ${publicKeyPEM}`); 187 | console.log(`Signature: ${signatureBase64}`); 188 | 189 | const isValid = await verifySignature(keyPair.publicKey, signatureBuffer, message); 190 | console.log(`Verification: ${isValid ? "Successful" : "Failed"}`); 191 | 192 | // Try Getting the PRIVATE key extracted 193 | console.log("--------------------------------------------\nNow Try Getting the PrivateKey!\n\nTHIS SHOULD FAIL!!!!!!!!!!!!!!"); 194 | const privateKey = await crypto.subtle.exportKey("jwk", keyPair.privateKey) 195 | } 196 | 197 | // Run the main function and log errors to the console 198 | main().catch(console.error); 199 | 200 | ``` 201 | 202 | ## How Does This Help With Session-Token Theft? 203 | 204 | 1. When the user is authenticating, have them sign their public-key with their private-key and send it to you. 205 | 2. Put the user's public-key inside of a JWT 206 | 3. For extra protection, put the user's IP-Address in the JWT as well 207 | 208 | Every time the user makes a request to something protected, in addition to sending the JWT, the user should send the following headers: 209 | ``` 210 | x-base64-jwt: {a base 64 encoded, stringifed version of the JWT being sent} 211 | x-base64-jwt-signature: {the ECDSA Signature of the x-base64-jwt} 212 | ``` 213 | 214 | Because the JWT contains the server-verified public key; I can verify the current signature (x-base64-jwt-signature) with it. If it fails, I proceed _exactly_ as if someone tampered a JWT in any other framework. 215 | 216 | If I want to be _extra_ secure, I can refuse request whose IP Address is different than what's in the JWT. 217 | --------------------------------------------------------------------------------