]> git.ardis.lu - static-encrypt-passkey/commitdiff
Initial commit
authorArdis Lu <ardisglu@gmail.com>
Sun, 20 Jul 2025 06:08:48 +0000 (23:08 -0700)
committerArdis Lu <ardisglu@gmail.com>
Sun, 20 Jul 2025 06:08:48 +0000 (23:08 -0700)
README.md [new file with mode: 0644]
index.html [new file with mode: 0644]
script.js [new file with mode: 0644]
style.css [new file with mode: 0644]

diff --git a/README.md b/README.md
new file mode 100644 (file)
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 (file)
index 0000000..a575fdd
--- /dev/null
@@ -0,0 +1,68 @@
+<!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
diff --git a/script.js b/script.js
new file mode 100644 (file)
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<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
diff --git a/style.css b/style.css
new file mode 100644 (file)
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
This page took 0.04438 seconds and 4 git commands to generate.