From: Ardis Lu Date: Sun, 20 Jul 2025 06:08:48 +0000 (-0700) Subject: Initial commit X-Git-Url: https://git.ardis.lu/?a=commitdiff_plain;h=cf01c60fb50f8592ac8f054ee3dc78e440aa3747;p=static-encrypt-passkey Initial commit --- cf01c60fb50f8592ac8f054ee3dc78e440aa3747 diff --git a/README.md b/README.md new file mode 100644 index 0000000..c23e04a --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# static-encrypt-passkey + +A tool to encrypt and decrypt any static content **using passkeys**. + +Use [static-encrypt](https://github.com/ardislu/static-encrypt) to encrypt using a password instead of a passkey. diff --git a/index.html b/index.html new file mode 100644 index 0000000..a575fdd --- /dev/null +++ b/index.html @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + static-encrypt-passkey + + + + +
+

static-encrypt-passkey

+

Encrypt and decrypt any static content using a passkey.

+

Use static-encrypt if you want to use a password instead of a passkey.

+

See the GitHub repository for more information.

+
+ +
+

Encrypt

+
+ + + + + +
+ +

Decrypt

+
+ + + + + +
+
+ + + + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..a5ccb54 --- /dev/null +++ b/script.js @@ -0,0 +1,131 @@ +/** + * Create a new passkey and get a pseudo-random value produced from the passkey PRF extension. + * @see {@link https://www.w3.org/TR/webauthn-3/#prf-extension} + * @param {ArrayBuffer|ArrayBufferView} challenge A cryptographic challenge for the passkey. + * @returns {Promise} A 32 byte long `ArrayBuffer` containing a pseudo-random + * value produced from the passkey PRF extension. + */ +async function createPrf(challenge) { + return navigator.credentials.create({ + publicKey: { + rp: { name: '' }, + user: { id: new ArrayBuffer(0), name: '', displayName: '' }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + extensions: { prf: { eval: { first: new ArrayBuffer(0) } } }, + challenge + } + }).then(c => c.getClientExtensionResults().prf.results.first); +} + +/** + * Request a "sign in" with a passkey created previously, if it exists. This function will return the same + * pseudo-random value produced during the passkey creation function. + * @see {@link https://www.w3.org/TR/webauthn-3/#prf-extension} + * @param {ArrayBuffer|ArrayBufferView} challenge A cryptographic challenge for the passkey. + * @returns {Promise} A 32 byte long `ArrayBuffer` containing a pseudo-random + * value produced from the passkey PRF extension. + */ +async function getPrf(challenge) { + return navigator.credentials.get({ + publicKey: { + extensions: { prf: { eval: { first: new ArrayBuffer(0) } } }, + challenge + } + }).then(c => c.getClientExtensionResults().prf.results.first); +} + +/** + * Create a `CryptoKey` from a high-entropy random value using the HKDF algorithm. + * @param {ArrayBuffer|ArrayBufferView} rand A high-entropy random value. + * @param {ArrayBuffer|ArrayBufferView} salt A cryptographic salt value. + * @param {ArrayBuffer|ArrayBufferView} info Additional contextual information. + * @returns {Promise} A `CryptoKey` that can be used for `"encrypt"` or `"decrypt"`. + */ +async function getKey(rand, salt, info) { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + rand, + 'HKDF', + false, + ['deriveKey'] + ); + + const key = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-512', + salt, + info + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + + return key; +} + +/** + * Encrypt a `string` by creating a new passkey then using the passkey's PRF extension to generate a + * HKDF key and encrypt the `string`. The `challenge`, `salt`, and `iv` values are embedded (in plaintext) + * alongside the ciphertext. + * @param {string} plaintext An unencrypted value to encrypt using a new passkey. + * @returns {Promise} The encrypted plaintext (i.e., ciphertext) with `challenge`, `salt`, and + * `iv` values in base64. + */ +export async function encrypt(plaintext) { + // Generate all required random values + const challenge = crypto.getRandomValues(new Uint8Array(64)); // For passkey + const salt = crypto.getRandomValues(new Uint8Array(64)); // For HKDF + const iv = crypto.getRandomValues(new Uint8Array(12)); // For AES-GCM + + // Prepare non-crypto inputs + const encoder = new TextEncoder(); + const encodedPlaintext = encoder.encode(plaintext); + const info = encoder.encode('https://github.com/ardislu/static-encrypt-passkey'); + + const prf = await createPrf(challenge); + const key = await getKey(prf, salt, info); + const ciphertext = new Uint8Array(await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + encodedPlaintext + )); + + const buffer = Uint8Array.from([...challenge, ...salt, ...iv, ...ciphertext]); + const content = btoa(String.fromCharCode(...buffer)); + + return content; +} + +/** + * Decrypt a `string` that has been encrypted with a passkey. Assuming the required `challenge`, `salt`, and + * `iv` values are embedded alongside the ciphertext. + * @param {string} content A base64-encoded ciphertext and required cryptographic values. + * @returns {Promise} The decrypted plaintext. + * @throws {OperationError} Decryption operation failed. + */ +export async function decrypt(content) { + // Extract random values + const buffer = Uint8Array.from(atob(content), c => c.charCodeAt(0)); + const challenge = buffer.slice(0, 64); // For passkey + const salt = buffer.slice(64, 128); // For HKDF + const iv = buffer.slice(128, 140); // For AES-GCM + const ciphertext = buffer.slice(140); + + // Prepare non-crypto inputs + const info = new TextEncoder().encode('https://github.com/ardislu/static-encrypt-passkey'); + + const prf = await getPrf(challenge); + const key = await getKey(prf, salt, info); + const encodedPlaintext = new Uint8Array(await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + ciphertext + )); + + const plaintext = new TextDecoder().decode(encodedPlaintext); + + return plaintext; +} \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..ee89c2d --- /dev/null +++ b/style.css @@ -0,0 +1,91 @@ +* { + margin: 0; + box-sizing: border-box; +} + +body { + font-family: system-ui, sans-serif; + place-items: center; + background: hsl(216deg 80% 95%); +} + +header { + margin: 32px 8px; +} + +header, +main { + inline-size: min(100dvi - 32px, 720px); +} + +header, +form { + display: flex; + flex-direction: column; + gap: 8px; + +} + +form { + position: relative; + margin-block-end: 32px; + + &::before { + content: ""; + display: block; + position: absolute; + inset-block-start: -8px; + inset-inline-start: -12px; + inline-size: 4px; + block-size: calc(100% + 8px); + background: hsl(216deg 50% 80%); + transition: background-color 400ms; + } + + &:focus-within::before { + background: hsl(216deg 75% 65%); + transition: background-color 200ms; + } +} + +label { + font-weight: 700; +} + +textarea, +button, +output { + padding: 4px 6px; + border: 1px solid hsl(216deg 50% 60%); + border-radius: 4px; +} + +textarea, +output { + min-block-size: 3.5em; + block-size: 10em; + overflow: auto; + resize: vertical; +} + +textarea { + font-family: inherit; + font-size: inherit; + flex: auto; +} + +button { + inline-size: fit-content; + cursor: pointer; + block-size: 32px; +} + +output { + white-space: pre-wrap; + word-break: break-all; + background: hsl(216deg 20% 95%); + + &[data-error] { + background: hsl(0deg 50% 80%); + } +} \ No newline at end of file