--- /dev/null
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta name="description" content="A tool to encrypt and decrypt any static content using passkeys.">
+ <meta property="og:title" content="static-encrypt-passkey">
+ <meta property="og:description" content="A tool to encrypt and decrypt any static content using passkeys.">
+ <meta property="og:type" content="website">
+ <meta property="og:url" content="https://static-encrypt-passkey.ardis.lu/">
+ <link rel="canonical" href="https://static-encrypt-passkey.ardis.lu/">
+ <link rel="icon" href="data:,">
+ <title>static-encrypt-passkey</title>
+ <link href="./style.css" rel="stylesheet">
+</head>
+
+<body>
+ <header>
+ <h1>static-encrypt-passkey</h1>
+ <p>Encrypt and decrypt any static content using a passkey.</p>
+ <p>Use <a href="https://static-encrypt.ardis.lu/">static-encrypt</a> if you want to use a password instead of a passkey.</p>
+ <p>See the <a href="https://github.com/ardislu/static-encrypt-passkey">GitHub repository</a> for more information.</p>
+ </header>
+
+ <main>
+ <h2>Encrypt</h2>
+ <form id="encrypt">
+ <label for="ec">Content</label>
+ <textarea name="content" id="ec" placeholder="Hello, world!" required></textarea>
+ <button>Encrypt with passkey</button>
+ <label for="eo">Encrypted content</label>
+ <output name="output" id="eo" for="ec"></output>
+ </form>
+
+ <h2>Decrypt</h2>
+ <form id="decrypt">
+ <label for="dc">Encrypted content</label>
+ <textarea name="content" id="dc" placeholder="l/b0RwTC..." required></textarea>
+ <button>Decrypt with passkey</button>
+ <label for="do">Content</label>
+ <output name="output" id="do" for="dc"></output>
+ </form>
+ </main>
+
+ <script type="module">
+ import { encrypt, decrypt } from './script.js';
+
+ document.querySelector('main').addEventListener('submit', async e => {
+ e.preventDefault();
+ const { content } = Object.fromEntries(new FormData(e.target));
+ const { output } = e.target.elements;
+ output.textContent = '';
+ let text;
+ try {
+ output.removeAttribute('data-error');
+ text = e.target.id === 'encrypt' ? await encrypt(content) : await decrypt(content);
+ }
+ catch (error) {
+ output.setAttribute('data-error', '');
+ text = error;
+ }
+ output.textContent = text;
+ });
+ </script>
+</body>
+
+</html>
\ No newline at end of file
--- /dev/null
+/**
+ * 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<ArrayBufferLike>} challenge A cryptographic challenge for the passkey.
+ * @returns {Promise<ArrayBuffer&{byteLength:32}>} 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<ArrayBufferLike>} challenge A cryptographic challenge for the passkey.
+ * @returns {Promise<ArrayBuffer&{byteLength:32}>} 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<ArrayBufferLike>} rand A high-entropy random value.
+ * @param {ArrayBuffer|ArrayBufferView<ArrayBufferLike>} salt A cryptographic salt value.
+ * @param {ArrayBuffer|ArrayBufferView<ArrayBufferLike>} info Additional contextual information.
+ * @returns {Promise<CryptoKey>} 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<string>} 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<string>} 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
--- /dev/null
+* {
+ 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