├── .editorconfig ├── .github └── workflows │ └── build-testbed.yml ├── .gitignore ├── .npmignore ├── BUNDLERS.md ├── LICENSE.txt ├── NON-BUNDLERS.md ├── README.md ├── bundler-plugins ├── astro.mjs ├── vite.mjs └── webpack.mjs ├── package.json ├── scripts ├── build-all.js ├── build-gh-pages.js └── postinstall.js ├── src ├── copyright-header.txt └── ldl.js └── test ├── index.html ├── spinner.js └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = tab 6 | indent_size = 4 7 | trim_trailing_whitespace = true 8 | 9 | [*.md] 10 | indent_style = space 11 | indent_size = 4 12 | 13 | [*.yml] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.github/workflows/build-testbed.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Build-TestBed 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the "main" branch 8 | push: 9 | branches: [ "main" ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | permissions: 15 | contents: read 16 | pages: write 17 | id-token: write 18 | 19 | # Allow one concurrent deployment 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: true 23 | 24 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 25 | jobs: 26 | deploy: 27 | environment: 28 | name: github-pages 29 | url: ${{ steps.deployment.outputs.page_url }} 30 | runs-on: ubuntu-latest 31 | steps: 32 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 33 | - uses: actions/checkout@v4 34 | 35 | # Runs a set of commands using the runners shell 36 | - name: install deps and build test bed 37 | run: | 38 | npm install 39 | npm run build:gh-pages 40 | - name: Setup Pages 41 | uses: actions/configure-pages@v5 42 | - name: Upload artifact 43 | uses: actions/upload-pages-artifact@v3 44 | with: 45 | # Upload built files 46 | path: './.gh-build' 47 | - name: Deploy to GitHub Pages 48 | id: deployment 49 | uses: actions/deploy-pages@v4 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .gh-build/ 4 | package-lock.json 5 | test/src 6 | test/dist 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .npmignore 3 | .gitignore 4 | .editorconfig 5 | node_modules/ 6 | .gh-build/ 7 | test/src 8 | test/dist 9 | -------------------------------------------------------------------------------- /BUNDLERS.md: -------------------------------------------------------------------------------- 1 | # Deploying Local-Data-Lock WITH A Bundler 2 | 3 | This project has non-ESM dependencies, which unfortunately cannot be *bundled* in with your other app code. Modern bundlers unfortunately don't out-of-the-box support configurations that can handle such a situation. 4 | 5 | As such, this project provides plugins for Astro, Vite, and Webpack, to take care of the various steps needed to get these non-ESM dependencies into an otherwise bundled web app built by those tools. 6 | 7 | ## Bundler Plugins 8 | 9 | The plugins for Astro, Vite, and Webpack are included in the `bundler-plugins/` directory. They should handle all necessary steps to load the dependencies. 10 | 11 | **Note:** You should not need to manually copy any files, as the plugins access the dependencies (in `node_modules`) directly to pull the files needed. But for reference, the files these plugins access are: 12 | 13 | * `@lo-fi/local-data-lock/dist/bundlers/ldl.mjs` 14 | 15 | ESM library module that's suitable for bundling and `import`ing into your web app. 16 | 17 | **Note:** this is *not* the same as `@lo-fi/local-data-lock/dist/auto/ldl.js`, which is only intended [for web application projects WITHOUT a bundler](NON-BUNDLERS.md) 18 | 19 | * `@byojs/storage/dist/util.mjs` 20 | * `@byojs/storage/dist/worker.opfs.mjs` 21 | * `@byojs/storage/dist/adapter.*.mjs` 22 | 23 | Client-side storage adapters 24 | 25 | * `@lo-fi/webauthn-local-client/dist/bundlers/walc.mjs` 26 | 27 | * `@lo-fi/webauthn-local-client/dist/bundlers/walc-external-bundle.js` 28 | 29 | Non-ESM (plain global .js) bundle of dependencies that must be loaded separately from (and prior to) your app's bundle. 30 | 31 | ### Astro Plugin (aka Integration) 32 | 33 | If using Astro 4+, it's strongly suggested to import this library's Astro-plugin to manage the loading of its non-ESM dependencies. Add something like the following to your `astro.config.mjs` file: 34 | 35 | ```js 36 | import { defineConfig } from "astro/config"; 37 | 38 | import LDL from "@lo-fi/local-data-lock/bundlers/astro"; 39 | 40 | export default defineConfig({ 41 | // .. 42 | 43 | integrations: [ LDL(), ], 44 | 45 | vite: { 46 | plugins: [ 47 | // pulls in some necessary bits of LDL's vite plugin 48 | LDL.vite(), 49 | ], 50 | 51 | optimizeDeps: { 52 | esbuildOptions: { 53 | // LDL's WALC dependency uses "top-level await", which is ES2022+ 54 | target: "es2022", 55 | }, 56 | }, 57 | 58 | build: { 59 | // LDL's WALC dependency uses "top-level await", which is ES2022+ 60 | target: "es2022" 61 | }, 62 | }, 63 | 64 | // .. 65 | }); 66 | ``` 67 | 68 | This plugin works for the `astro dev` (dev-server), as well as `astro build` / `astro preview` modes. In all cases, it copies the `@lo-fi/local-data-lock/dist/bundlers/walc-external-bundle.js` file into the `public/` directory of your project root, as well as the `dist/` directory when running a build. It also injects an inline `` tag into the markup of the `index.html` file that Vite produces for your app. 102 | 103 | **Note:** At present, this plugin is not configurable in any way (i.e., calling `LDL()` above with no arguments). If something about its behavior is not compatible with your Vite project setup -- which can vary widely and be quite complex to predict or support by a basic plugin -- it's recommended you simply copy over the `@lo-fi/local-data-lock/bundler-plugins/vite.mjs` plugin and make necessary changes. 104 | 105 | #### Top-level `await` 106 | 107 | This library's main dependency (**WebAuthn-Local-Client**) uses ["top-level `await`"](https://github.com/tc39/proposal-top-level-await), a feature added to JS in ES2022. The current default target for Vite seems to be browsers older than this, so the above config explicitly sets the *targets* to `"es2022"`. 108 | 109 | You may experience issues where your tooling/configuration either ignores this setting, or otherwise breaks with it set. This may variously result in seeing an error about the top-level `await`s in this library being incompatible with the built-target, or an error about `await` needing to only be in `async function`s or the top-level of a module (which it is!). 110 | 111 | You may need to configure Vite to skip trying to optimize the `walc.mjs` file during bundling, something like: 112 | 113 | ```js 114 | export default defineConfig({ 115 | 116 | // .. 117 | 118 | optimizeDeps: { 119 | exclude: [ "@lo-fi/webauthn-local-client" ] 120 | } 121 | 122 | // .. 123 | }); 124 | ``` 125 | 126 | #### SSR Breakage 127 | 128 | An unfortunate gotcha of some tools that wrap Vite (e.g., Nuxt, etc) and do SSR (server-side rendering) is that they *break* a key assumption/behavior of this module's Vite plugin: the HTML injection of ``. 129 | 130 | As such, you'll likely need to manually add that `` tag into the markup of the `index.html` file (and any other HTML files) that Webpack produces for your app. 161 | 162 | **Note:** At present, this plugin is not configurable in any way (i.e., calling `LDL()` above with no arguments). If something about its behavior is not compatible with your Webpack project setup -- which can vary widely and be quite complex to predict or support by a basic plugin -- it's recommended you simply copy over the `@lo-fi/local-data-lock/bundler-plugins/webpack.mjs` plugin and make necessary changes. 163 | 164 | ## Import/Usage 165 | 166 | To import and use **local-data-lock** in a *bundled* browser app: 167 | 168 | ```js 169 | import { getLockKey, lockData, unlockData } from "@lo-fi/local-data-lock"; 170 | ``` 171 | 172 | When `import`ed like this, Astro, Vite, Webpack should (via these plugins) properly find and bundle the `@lo-fi/local-data-lock/dist/bundlers/ldl.mjs` ESM library module with the rest of your app code, hopefully without any further steps necessary. 173 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Kyle Simpson 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /NON-BUNDLERS.md: -------------------------------------------------------------------------------- 1 | # Deploying Local-Data-Lock WITHOUT A Bundler 2 | 3 | To use this library directly -- i.e., in a classic/vanilla web project without a modern bundler tool -- make a directory for it (e.g., `local-data-lock/`) in your browser app's JS assets directory. 4 | 5 | Then copy over all `@lo-fi/local-data-lock/dist/auto/*` contents, as-is: 6 | 7 | * `@lo-fi/local-data-lock/dist/auto/ldl.js` 8 | 9 | **Note:** this is *not* the same as `@lo-fi/local-data-lock/dist/bundlers/ldl.mjs`, which is only intended [for web application projects WITH a bundler](BUNDLERS.md) 10 | 11 | * `@byojs/storage/dist/util.mjs` 12 | * `@byojs/storage/dist/worker.opfs.mjs` 13 | * `@byojs/storage/dist/adapter.*.mjs` 14 | 15 | Client-side storage adapters 16 | 17 | * `@lo-fi/local-data-lock/dist/auto/external/*` (preserve the whole `external/` sub-directory): 18 | - `@lo-fi/webauthn-local-client/walc.js` 19 | - `@lo-fi/webauthn-local-client/external.js` 20 | - `@lo-fi/webauthn-local-client/external/asn1.all.min.js` 21 | - `@lo-fi/webauthn-local-client/external/cbor.js` 22 | - `@lo-fi/webauthn-local-client/external/libsodium.js` 23 | - `@lo-fi/webauthn-local-client/external/libsodium-wrappers.js` 24 | 25 | ## Import/Usage 26 | 27 | To import and use **local-data-lock** in a *non-bundled* browser app: 28 | 29 | ```js 30 | import { getLockKey, lockData, unlockData } from "/path/to/js-assets/local-data-lock/ldl.js"; 31 | ``` 32 | 33 | ## Using Import Map 34 | 35 | If your **non-bundled** browser app has an [Import Map](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap) in its HTML (strongly recommended!), you can improve the `import` by adding an entries for this library and its dependencies: 36 | 37 | ```html 38 | 47 | ``` 48 | 49 | Now, you'll be able to `import` the library in your app in a friendly/readable way: 50 | 51 | ```js 52 | import { getLockKey, lockData, unlockData } from "local-data-lock"; 53 | ``` 54 | 55 | **Note:** If you omit the above `"local-data-lock"` import-map entry, you can still `import` **local-data-lock** by specifying the proper path to `ldl.js` (as shown above). However, the entry above for `"@lo-fi/webauthn-local-client"` is more required. Alternatively, you'll have to make the following manual edits: 56 | 57 | * edit the `ldl.js` file to change its `import` specifier for `"@lo-fi/webauthn-local-client"` to the proper path to `walc.js`. 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Local Data Lock 2 | 3 | [![npm Module](https://badge.fury.io/js/@lo-fi%2Flocal-data-lock.svg)](https://www.npmjs.org/package/@lo-fi/local-data-lock) 4 | [![License](https://img.shields.io/badge/license-MIT-a1356a)](LICENSE.txt) 5 | 6 | **Local Data Lock** provides a simple utility interface for encrypting and decrypting (and producing digital signatures for) local-first application data using a keypair stored and protected by [WebAuthn](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) (biometric passkeys), via the [**WebAuthn Local Client** library](https://github.com/mylofi/webauthn-local-client) -- no servers required! 7 | 8 | ```js 9 | var lockKey = await getLockKey({ .. }); 10 | 11 | var encData = await lockData({ hello: "World!" },lockKey); 12 | // "aG4/z..." 13 | 14 | await unlockData(encData,lockKey); 15 | // { hello: "World!" } 16 | ``` 17 | 18 | ---- 19 | 20 | [Library Tests (Demo)](https://mylofi.github.io/local-data-lock/) 21 | 22 | ---- 23 | 24 | ## Overview 25 | 26 | This library can securely lock (encrypt) data in the local client, with no servers needed. The encrypted data *might also be* stored locally on the client device; for this purpose, please strongly consider using the [**Local Vault** library](https://github.com/mylofi/local-vault). 27 | 28 | However, the encrypted data (by default, represented as a base64 encoded string) might be transmitted and stored elsewhere, such as on an app's servers. The cryptographic keypair may also be used for digital signatures to verify secure data transmission, using the `signData()` and `verifySignature()` methods. 29 | 30 | This cryptographic keypair is protected locally on the user's device in a biometric passkey; the user can easily unlock (decrypt) their data, or verify a received data transmission from their other device, by presenting a biometric factor to retrieve the keypair. 31 | 32 | ### How does it work? 33 | 34 | One direct dependency of this library is [**WebAuthn-Local-Client**](https://github.com/mylofi/webauthn-local-client), which utilizes the browser's [WebAuthn API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API) for managing biometric passkeys entirely in the local client (zero servers). 35 | 36 | The cryptographic keypair the library generates, is attached securely to a passkey, which is protected by the authenticator/device. The library also stores meta-data entries for these passkeys -- specifically, the public-key info for the passkey itself, which is necessary for **verifying** subsequent passkey authentication responses. 37 | 38 | **NOTE:** This public-key for a passkey is *NOT* in any way related to the crytographic keypair, which **Local Data Lock** does not persist anywhere on the device (only kept in memory). It's *only* used for authentication verification -- protecting against MitM attacks against the authenticator. Verification defaults *on*, but can be skipped by passing `verify: false` as an option to the `getLockKey()` method. 39 | 40 | The client-side storage location (for passkey account metadata) that **Local Data Lock** uses, is [configurable (defaults to IndexedDB)](#configuring-client-side-storage). 41 | 42 | ### Security vs Convenience 43 | 44 | Your application accesses the cryptographic keypair via `getLockKey()`, and may optionally decide if you want to persist it somewhere -- for more convenience/ease-of-use, as compared to asking the user to re-authenticate their passkey on each usage. But you are cautioned to be very careful in such decisions, striking an appropriate balance between security and convenience. 45 | 46 | If the design is *too convenient* (e.g., once-forever logins), it's likely to be insecure (and the user may not realize it!). If the design is *too secure*, it's likely to have so much UX friction that users won't use it (or your app). 47 | 48 | To assist in making these difficult tradeoffs, **Local Data Lock** internally caches the cryptographic keypair after a successful passkey authentication, and keeps it in memory (assuming no page refresh) for a period of time (by default, 30 minutes); in such a setup, the user won't need to re-authenticate their passkey more often than once per 30 minutes. This default time threshold can also be adjusted, from 0ms upward, [using `configure({ cacheLifetime: .. })`](#change-lock-key-cache-lifetime). 49 | 50 | You are strongly encouraged **NOT** to persist the encryption/decryption key, and to instead rely on this time-based caching mechanism. 51 | 52 | ## Deployment / Import 53 | 54 | ```cmd 55 | npm install @lo-fi/local-data-lock 56 | ``` 57 | 58 | The [**@lo-fi/local-data-lock** npm package](https://npmjs.com/package/@lo-fi/local-data-lock) includes a `dist/` directory with all files you need to deploy **Local Data Lock** (and its dependencies) into your application/project. 59 | 60 | **Note:** If you obtain this library via git instead of npm, you'll need to [build `dist/` manually](#re-building-dist) before deployment. 61 | 62 | * **USING A WEB BUNDLER?** (Astro, Vite, Webpack, etc) Use the `dist/bundlers/*` files and see [Bundler Deployment](BUNDLERS.md) for instructions. 63 | 64 | * Otherwise, use the `dist/auto/*` files and see [Non-Bundler Deployment](NON-BUNDLERS.md) for instructions. 65 | 66 | ## `WebAuthn` Supported? 67 | 68 | To check if `WebAuthn` API and functionality is supported on the device, consult the `supportsWebAuthn` exported boolean. 69 | 70 | Additionally, **Local Data Lock** requires the authenticator to support ["user verification"](https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/isUserVerifyingPlatformAuthenticatorAvailable_static). Thus, a separate exported boolean called `supportsWAUserVerification` should also be checked: 71 | 72 | ```js 73 | import { supportsWebAuthn, supportsWAUserVerification } from ".."; 74 | 75 | if (supportsWebAuthn && supportsWAUserVerification) { 76 | // welcome to the future, without passwords! 77 | } 78 | else { 79 | // sigh, use fallback authentication, like 80 | // icky passwords :( 81 | } 82 | ``` 83 | 84 | ## Registering a local account (and lock-key keypair) 85 | 86 | A "local account" is merely a collection of one or more passkeys that are all holding the same encryption/decryption keypair. There's no limit on the number of "local account" passkey collections on a device (other than device storage limits). 87 | 88 | To register a new local account: 89 | 90 | ```js 91 | import { getLockKey } from ".."; 92 | 93 | var key = await getLockKey({ addNewPasskey: true }); 94 | ``` 95 | 96 | The returned keypair result will also include a `localIdentity` property, with a unique ID (`string` value) for the local account. This local account ID should be stored by your application (in local-storage, cookie, etc): 97 | 98 | ```js 99 | var currentAccountID = key.localIdentity; 100 | ``` 101 | 102 | ### Lock-Key Value Format 103 | 104 | Other than reading the `localIdentity` property, the lock-key object should be **treated opaquely**, meaning that you don't rely on its structure, don't make any changes to it, etc. 105 | 106 | It contains binary data for the keypairs, in the form of various `Uint8Array` values. These types of data are not, as-is, particularly serialization safe (JSON, etc), for the purposes of storage or transmission. To serialize these binary-array values (and unserialize them later), you can use the `toBase64String()` / `fromBase64String()` utilities exported on the library's API. 107 | 108 | For example, to serialize a lock-key for JSON-safe storage, or transmission: 109 | 110 | ```js 111 | var serializedKey = Object.fromEntries( 112 | Object.entries(key) 113 | .map(([ prop, value ]) => [ 114 | prop, 115 | ( 116 | value instanceof Uint8Array && 117 | value.buffer instanceof ArrayBuffer 118 | ) ? 119 | toBase64String(value) : 120 | value 121 | ]) 122 | ); 123 | ``` 124 | 125 | And to deserialize: 126 | 127 | ```js 128 | var key = Object.fromEntries( 129 | Object.entries(serializedKey) 130 | .map(([ prop, value ]) => [ 131 | prop, 132 | ( 133 | typeof value == "string" && 134 | 135 | // padded base64 encoding of Uint8Array(32) 136 | // will be at least 44 characters long 137 | value.length >= 44 138 | ) ? 139 | fromBase64String(value) : 140 | value 141 | ]) 142 | ); 143 | ``` 144 | 145 | ### Obtaining the keypair from existing account/passkey 146 | 147 | If the `currentAccountID` (as shown above) is available, it should be used in subsequent calls to `getLockKey()` when re-obtaining the encryption/decryption keypair from the existing passkey: 148 | 149 | ```js 150 | var key = await getLockKey({ localIdentitity: currentAccountID }); 151 | ``` 152 | 153 | If you don't have (or the application loses) an account ID, call `listLocalIdentities()` (async) to receive an array of all registed local account IDs. 154 | 155 | Alternatively, `getLockKey()` can be called WITHOUT either `localIdentity` or `addNewPasskey` options, in which case the device will prompt the user to select a discoverable passkey (if supported). If the user chooses a passkey that matches one of the registered local accounts, the keypair (and its `localIdentity` account ID property) will be returned. 156 | 157 | ### Adding alternate passkeys to an account 158 | 159 | Users may prefer a more robust security setup (less chance of identity/data loss), by registering more than one passkey (for example, FaceID + TouchID) -- each holds a copy of the encryption/decryption keypair. 160 | 161 | To prompt for adding a new passkey to an existing local account: 162 | 163 | ```js 164 | /*var key =*/ await getLockKey({ 165 | localIdentitity: currentAccountID, 166 | addNewPasskey: true 167 | }); 168 | ``` 169 | 170 | ### Change lock-key cache lifetime 171 | 172 | To change the default (30 minutes) lifetime for caching the encryption/decryption keypair (extracted from passkey authentication): 173 | 174 | ```js 175 | import { configure } from ".."; 176 | 177 | // change default lifetime to 5 minutes 178 | configure({ cacheLifetime: 5 * 60 * 1000 }); 179 | ``` 180 | 181 | ### Manually cache a lock-key 182 | 183 | To manually cache a lock-key silently (without passkey prompt): 184 | 185 | ```js 186 | import { cacheLockKey } from ".."; 187 | 188 | cacheLockKey(existingLockKey); 189 | ``` 190 | 191 | **WARNING:** This is generally not recommended; it's provided only for advanced use-cases, such as a lock-key being preserved (temporarily) to approximate a "login session" across multiple page loads. Avoid this approach unless you're certain you need it, as it can degrade some of the security assurances behind the design of this library. 192 | 193 | ### Clear the lock-key cache 194 | 195 | To clear a cache entry (effectively, "logging out"): 196 | 197 | ```js 198 | import { clearLockKeyCache } from ".."; 199 | 200 | clearLockKeyCache(currentAccountID); 201 | ``` 202 | 203 | To clear *all* cache entries, omit the local account ID: 204 | 205 | ```js 206 | clearLockKeyCache(); 207 | ``` 208 | 209 | ### Removing a local account 210 | 211 | To remove a local account (from device local storage), thereby discarding associated passkey public-key info (necessary for verifying passkey authentication responses): 212 | 213 | ```js 214 | import { removeLocalAccount } from ".."; 215 | 216 | await removeLocalAccount(currentAccountID); 217 | ``` 218 | 219 | ### Configuring Passkeys 220 | 221 | There are several options available to the `getLockKey()` method, to customize the information used when registering passkeys: 222 | 223 | ```js 224 | var key = await getLockKey({ 225 | addNewPasskey: true, // or "localIdentity: .." + "resetLockKey: true" 226 | 227 | /* passkey configuration options: */ 228 | username: "a-local-username", 229 | displayName: "A Local Username", 230 | relyingPartyID: "myappdomain.tld", 231 | relyingPartyName: "My App" 232 | }); 233 | ``` 234 | 235 | All of these passkey configuration options are string values, passed along to the `WebAuthn` API subsystem; they affect how the device saves the passkey once registered, and further verifies its usage later. 236 | 237 | The `username` (default: `"local-user"`) and `displayName` (default: `"Local User"`) options are information the system uses in its modal dialogs to indicate to the user which passkey they are using in authentication operations; this library only preserves them for non-functional, metadata/debugging purposes. Ideally, your application should prompt the user for these values before initial passkey registration, or auto-generate values that will make sense to the user. 238 | 239 | **Note:** The values don't strictly need to be unique, but if a user registers multiple passkeys with the same username/display-name, it may be confusing to them in future authentications. 240 | 241 | The `relyingPartyID` should be the canonical hostname of the web application, or matching an application's package ID (e.g., `com.app.my-favorite`) if it's an app-store installable application. Likewise, `relyingPartyName` (`My Favorite App`) should be a human-friendly name for your application that users will recognize; some devices will display this value in the passkey modal dialogs along with the `username` / `displayName` values. 242 | 243 | Three of the options (`username`, `displayName`, and `relyingPartName`) are *only* valid when creating a new passkey, in either `addNewPasskey: true` or `resetLockKey: true` modes; the `relyingPartyID` option can/should be used in all `getLockKey()` calls. 244 | 245 | ### Canceling Pending Lock-Key Request 246 | 247 | If a call to `getLockKey(..)` requires a passkey (re)authentication, there may be a substantial delay while the user is navigating the system prompts. Calling `getLockKey()` a subsequent time, while another `getLockKey()` is currently pending, will abort that previous call -- and should cancel any open system dialogs the user is interacting with. 248 | 249 | However, you may want to cancel a currently pending `getLockKey()` *without* having to call `getLockKey()` again, for example based on a timeout if authentication is taking too long. To be able to cancel this asynchronous operation, pass in an [`AbortController.signal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController/signal) instance, as a `signal` option to `getLockKey()`: 250 | 251 | ```js 252 | var cancelToken = new AbortController(); 253 | var key = await getLockKey({ 254 | /* .. */, 255 | signal: cancelToken.signal 256 | }); 257 | 258 | // elsewhere: 259 | cancelToken.abort("Taking too long!"); 260 | ``` 261 | 262 | Aborting a cancellation token while the `getLockKey()` is still pending (i.e., at an `await`), will by default throw an exception at that point. However, in some UX flows -- such as intending to call `getLockKey()` again with different options -- you may want to silently cancel that currently pending `getLockKey()` *without* throwing an exception. 263 | 264 | Pass the `resetAbortReason` value to the `abort()` call: 265 | 266 | ```js 267 | import { resetAbortReason, getLockKey } from ".."; 268 | 269 | var key = await getLockKey({ 270 | /* .. */, 271 | signal: cancelToken.signal 272 | }); 273 | 274 | // elsewhere: 275 | cancelToken.abort(resetAbortReason); 276 | ``` 277 | 278 | The current `getLockKey()` will now cleanly and silently cancel, **and its return value will be `undefined`**. 279 | 280 | ## Encrypt some data 281 | 282 | Once a keypair has been obtained, to encrypt application data: 283 | 284 | ```js 285 | import { lockData } from ".."; 286 | 287 | var encData = lockData(someData,key); 288 | ``` 289 | 290 | The `lockData()` method will auto-detect the type of `someData`, so most any value (even a JSON-compatible object) is suitable to pass in. 291 | 292 | **Note:** If `someData` is already an array-buffer or typed-array, no transformation is necessary. If it's an object, a JSON string serialization is attempted. Otherwise, a string coercion is performed on the value. Regardless, the resulting string is then converted to a typed-array representation for encryption. 293 | 294 | The default representation in the return value (`encData`) will be a base64 encoded string (suitable for storing in LocalStorage, transmitting in JSON, etc). If you prefer the `Uint8Array` binary representation: 295 | 296 | ```js 297 | var encDataBuffer = lockData( 298 | someData, 299 | key, 300 | { outputFormat: "raw" } // instead of "base64" 301 | ); 302 | // Uint8Array[ .. ] 303 | ``` 304 | 305 | ## Decrypt some data 306 | 307 | With the keypair and a previously encrypted data value (from `lockData()`), decryption can be performed: 308 | 309 | ```js 310 | import { unlockData } from ".."; 311 | 312 | var data = unlockData(encData,key); 313 | ``` 314 | 315 | The `unlockData()` method will auto-detect the type of `encData` (either the base64 string encoding, or the `Uint8Array` binary encoding). 316 | 317 | By default, the decrypted data is assumed to be a utf-8 encoded string, with a JSON serialized value to be parsed. But if you are not encrypting/decrypting JSON-compatible data objects, set the `parseJSON: false` option: 318 | 319 | ```js 320 | var dataStr = unlockData( 321 | encData, 322 | key, 323 | { parseJSON: false } 324 | ); 325 | ``` 326 | 327 | If you want the raw `Uint8Array` binary representation returned, instead of the utf-8 string: 328 | 329 | ```js 330 | var dataBuffer = unlockData( 331 | encData, 332 | key, 333 | { outputFormat: "raw" } // instead of "utf8" (or "utf-8") 334 | ); 335 | ``` 336 | 337 | ## Signing data and verifying signatures 338 | 339 | In addition to encryption and decryption, lock-keys can be used for producing and verifying (detached) digital signatures. 340 | 341 | To sign a piece of data: 342 | 343 | ```js 344 | import { signData } from ".."; 345 | 346 | var signature = signData(someData,lockKey); // "Zt83H.." 347 | ``` 348 | 349 | `someData` can be any string or a JSON-serializable object; any other primitive value will be treated as a string. `lockKey` only strictly requires a `privateKey` property (from a full lock-key value). 350 | 351 | The default representation in the return value will be a base64 encoded string (suitable for storing in LocalStorage, transmitting in JSON, etc). If you prefer the `Uint8Array` binary representation: 352 | 353 | ```js 354 | var signature = signData( 355 | someData, 356 | key, 357 | { outputFormat: "raw" } // instead of "base64" 358 | ); 359 | // Uint8Array[ .. ] 360 | ``` 361 | 362 | To verify a signature: 363 | 364 | ```js 365 | import { verifySignature } from ".."; 366 | 367 | verifySignature(someData,lockKey,signature); // true (or false!) 368 | ``` 369 | 370 | Obviously, `someData` needs to hold the exact same data as was passed to `signData()` previously. `lockKey` only strictly requires a `publicKey` property (from a full lock-key value). `signature` may be a string (assumed as the base64 encoded representation) or the raw `Uint8Array` binary representation. 371 | 372 | The function returns `true` / `false` for verification -- or throws an exception if the data, key, or signature are malformed/invalid. 373 | 374 | ## Deriving an encryption/decryption key 375 | 376 | If you want to manually derive the keypair information from a secure random seed value (`Uint8Array` with enough random entropy): 377 | 378 | ```js 379 | import { deriveLockKey } from ".."; 380 | 381 | var key = deriveLockKey(seedValue); 382 | ``` 383 | 384 | This keypair is suitable to use with `lockData()` and `unlockData()` methods. However, the keypair returned WILL NOT be associated with (or protected by) a device passkey; it receives no entry in the device's local-storage and will not be returned from `getLockKey()`. The intent of this library is to rely on passkeys, so you are encouraged *not* to pursue this manual approach unless strictly necessary. 385 | 386 | Further, to generate a suitable cryptograhpically random `seedValue`: 387 | 388 | ```js 389 | import { generateEntropy } from ".."; 390 | 391 | var seedValue = generateEntropy(32); 392 | ``` 393 | 394 | **Note:** The encryption/decryption keypairs this library uses (via underlying libsodium methods) require specifically 32 bytes (256 bits) of entropy for the seed value. 395 | 396 | The returned `seedValue` will be a raw `Uint8Array` binary typed-array. 397 | 398 | ## Importing an encryption/decryption key 399 | 400 | If you have a lock-key keypair generated by **Local Vault** / **Local Data Lock**, either from manually calling [`deriveLockKey()`](#deriving-an-encryptiondecryption-key), or from a previous call to `getLockKey()` (even on another device!), you *can* choose to import it to a local account. 401 | 402 | When registering a new local-account: 403 | 404 | ```js 405 | var key = await getLockKey({ 406 | addNewPasskey: true, 407 | useLockKey: existingLockKey 408 | }); 409 | key === existingLockKey; // true 410 | ``` 411 | 412 | When resetting the key on an existing local-account: 413 | 414 | ```js 415 | var key = await getLockKey({ 416 | localIdentitity: currentAccountID, 417 | resetLockKey: true, 418 | useLockKey: existingLockKey 419 | }); 420 | key === existingLockKey; // true 421 | ``` 422 | 423 | **Warning:** You should generally let **Local Data Lock** internally generate and manage the lock-keys on local-accounts, and should not store (or transmit) these lock-keys in a way that degrades the security promises of this library. Be very careful if you are using the library in a way that you need to use `useLockKey`, and make sure it's absolutely necessary. 424 | 425 | ## Configuring client-side storage 426 | 427 | By default, **Local Data Lock** will store its [passkey account metadata](#how-does-it-work) in [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API), with the **Storage** library's `idb` storage adapter. 428 | 429 | However, you may wish to configure to use one of the other client storage mechanisms: 430 | 431 | ```js 432 | import { configure } from ".."; 433 | 434 | // override default storage to Local-Storage 435 | // (instead of IndexedDB) 436 | configure({ accountStorage: "local-storage" }); 437 | ``` 438 | 439 | **WARNING:** If you need to configure `accountStorage` as shown, make sure to do so just once (per page load), *before* any other calls to any other **Local Data Lock** methods, to prevent any confusion of where the passkey account metadata is held. 440 | 441 | The corresponding (or default) **Storage** adapter will be loaded dynamically (i.e., from `"@byojs/storage/*"`), at the first need for **Local Data Lock** to access or update its passkey account metadata storage. 442 | 443 | ### Manually specifying custom storage adapter 444 | 445 | If you want to use a custom storage adapter -- one *not* [provided by **Storage**](https://github.com/byojs/storage?tab=readme-ov-file#client-side-storage-adapters) -- pass the storage adapter instance directly to `configure()`: 446 | 447 | ```js 448 | import { configure } from ".."; 449 | 450 | configure({ accountStorage: customStorageAdapter }); 451 | ``` 452 | 453 | **NOTE:** The adapter instance (`customStorageAdapter`) *must* conform to the [storage-adapter API as defined by **Storage**](https://github.com/byojs/storage?tab=readme-ov-file#storage-api). 454 | 455 | ## WebAuthn-Local-Client Utilities 456 | 457 | The following utilities are re-exported from the [`WebAuthn-Local-Client` dependency](https://github.com/mylofi/webauthn-local-client): 458 | 459 | * `toBase64String()` - from `Uint8Array` to string in base64 encoding 460 | * `fromBase64String()` - from base64 encoded string to `Uint8Array` 461 | * `toUTF8String()` - from `Uint8Array` to string in utf-8 string 462 | * `fromUTF8String()` - from utf-8 string to `Uint8Array` 463 | * `packPublicKeyJSON()` / `unpackPublicKeyJSON()` -- these are specifically for a passkey entry's `publicKey` property, when being stored/retrieved from `localStroage` 464 | 465 | These utilities are helpful when dealing with converting values between various representations, especially for storing values (i.e., `localStorage`, etc). 466 | 467 | ## Re-building `dist/*` 468 | 469 | If you need to rebuild the `dist/*` files for any reason, run: 470 | 471 | ```cmd 472 | # only needed one time 473 | npm install 474 | 475 | npm run build:all 476 | ``` 477 | 478 | ## Tests 479 | 480 | Since the library involves non-automatable behaviors (requiring user intervention in browser), an automated unit-test suite is not included. Instead, a simple interactive browser test page is provided. 481 | 482 | Visit [`https://mylofi.github.io/local-data-lock/`](https://mylofi.github.io/local-data-lock/), and follow instructions in-page from there to perform the interactive tests. 483 | 484 | ### Run Locally 485 | 486 | To instead run the tests locally, first make sure you've [already run the build](#re-building-dist), then: 487 | 488 | ```cmd 489 | npm test 490 | ``` 491 | 492 | This will start a static file webserver (no server logic), serving the interactive test page from `http://localhost:8080/`; visit this page in your browser to perform tests. 493 | 494 | By default, the `test/test.js` file imports the code from the `src/*` directly. However, to test against the `dist/auto/*` files (as included in the npm package), you can modify `test/test.js`, updating the `/src` in its `import` statement to `/dist` (see the import-map in `test/index.html` for more details). 495 | 496 | ## License 497 | 498 | [![License](https://img.shields.io/badge/license-MIT-a1356a)](LICENSE.txt) 499 | 500 | All code and documentation are (c) 2024 Kyle Simpson and released under the [MIT License](http://getify.mit-license.org/). A copy of the MIT License [is also included](LICENSE.txt). 501 | -------------------------------------------------------------------------------- /bundler-plugins/astro.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | import vitePlugin from "./vite.mjs"; 4 | import walcAstroPlugin from "@lo-fi/webauthn-local-client/bundlers/astro"; 5 | 6 | 7 | // ******************************** 8 | 9 | export default LDL; 10 | 11 | 12 | // ******************************** 13 | 14 | function LDL() { 15 | var walcAstro = walcAstroPlugin(); 16 | var vite = vitePlugin(); 17 | 18 | LDL.vite = () => { 19 | // copy a subset of the vite plugin hooks that are still 20 | // necessary, even though astro plugin is mostly taking 21 | // over the task 22 | return { 23 | name: vite.name, 24 | enforce: vite.enforce, 25 | resolveId: vite.resolveId, 26 | load: vite.load, 27 | }; 28 | }; 29 | 30 | return { 31 | ...walcAstro, 32 | name: "astro-plugin-ldl", 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /bundler-plugins/vite.mjs: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import fs from "node:fs"; 3 | import fsp from "node:fs/promises"; 4 | 5 | import WALC from "@lo-fi/webauthn-local-client/bundlers/vite"; 6 | 7 | 8 | // ******************************** 9 | 10 | export default LDL; 11 | 12 | 13 | // ******************************** 14 | 15 | function LDL() { 16 | var ldlSrcPath; 17 | 18 | var walcVite = WALC(); 19 | 20 | return { 21 | ...walcVite, 22 | 23 | name: "vite-plugin-ldl", 24 | 25 | async configResolved(cfg) { 26 | var bundlersDir = path.join(cfg.root,"node_modules","@lo-fi","local-data-lock","dist","bundlers"); 27 | ldlSrcPath = path.join(bundlersDir,"ldl.mjs"); 28 | 29 | return walcVite.configResolved(cfg); 30 | }, 31 | 32 | load(id,opts) { 33 | if (id == "@lo-fi/local-data-lock") { 34 | return fs.readFileSync(ldlSrcPath,{ encoding: "utf8", }); 35 | } 36 | return walcVite.load(id,opts); 37 | }, 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /bundler-plugins/webpack.mjs: -------------------------------------------------------------------------------- 1 | // References: 2 | // https://github.com/principalstudio/html-webpack-inject-preload 3 | // https://github.com/icelam/html-inline-script-webpack-plugin 4 | 5 | import path from "node:path"; 6 | import fs from "node:fs"; 7 | import fsp from "node:fs/promises"; 8 | 9 | import WALC from "@lo-fi/webauthn-local-client/bundlers/webpack"; 10 | 11 | 12 | // ******************************** 13 | 14 | export default WALC; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lo-fi/local-data-lock", 3 | "description": "Protect local-first app data with encryption/decryption key secured in WebAuthn (biometric) passkeys", 4 | "version": "0.17.0", 5 | "exports": { 6 | ".": "./dist/bundlers/ldl.mjs", 7 | "./bundlers/astro": "./bundler-plugins/astro.mjs", 8 | "./bundlers/vite": "./bundler-plugins/vite.mjs", 9 | "./bundlers/webpack": "./bundler-plugins/webpack.mjs" 10 | }, 11 | "browser": { 12 | "@lo-fi/local-data-lock": "./dist/bundlers/ldl.mjs" 13 | }, 14 | "scripts": { 15 | "build:all": "node scripts/build-all.js", 16 | "build:gh-pages": "npm run build:all && node scripts/build-gh-pages.js", 17 | "build": "npm run build:all", 18 | "test:start": "npx http-server test/ -p 8080", 19 | "test": "npm run test:start", 20 | "postinstall": "node scripts/postinstall.js", 21 | "prepublishOnly": "npm run build:all" 22 | }, 23 | "dependencies": { 24 | "@byojs/storage": "~0.12.1", 25 | "@lo-fi/webauthn-local-client": "~0.3000.0" 26 | }, 27 | "devDependencies": { 28 | "micromatch": "~4.0.8", 29 | "recursive-readdir-sync": "~1.0.6", 30 | "terser": "~5.37.0" 31 | }, 32 | "repository": { 33 | "type": "git", 34 | "url": "git+https://github.com/mylofi/local-data-lock.git" 35 | }, 36 | "keywords": [ 37 | "local-first", 38 | "encryption" 39 | ], 40 | "bugs": { 41 | "url": "https://github.com/mylofi/local-data-lock/issues", 42 | "email": "getify@gmail.com" 43 | }, 44 | "homepage": "https://github.com/mylofi/local-data-lock", 45 | "author": "Kyle Simpson ", 46 | "license": "MIT" 47 | } 48 | -------------------------------------------------------------------------------- /scripts/build-all.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | var fsp = require("fs/promises"); 8 | 9 | var micromatch = require("micromatch"); 10 | var recursiveReadDir = require("recursive-readdir-sync"); 11 | var terser = require("terser"); 12 | 13 | const PKG_ROOT_DIR = path.join(__dirname,".."); 14 | const SRC_DIR = path.join(PKG_ROOT_DIR,"src"); 15 | const MAIN_COPYRIGHT_HEADER = path.join(SRC_DIR,"copyright-header.txt"); 16 | const LDL_SRC = path.join(SRC_DIR,"ldl.js"); 17 | const NODE_MODULES_DIR = path.join(PKG_ROOT_DIR,"node_modules"); 18 | const LOFI_WALC_DIST_DIR = path.join(NODE_MODULES_DIR,"@lo-fi","webauthn-local-client","dist"); 19 | const LOFI_WALC_DIST_AUTO_DIR = path.join(LOFI_WALC_DIST_DIR,"auto"); 20 | const LOFI_WALC_DIST_BUNDLERS_DIR = path.join(LOFI_WALC_DIST_DIR,"bundlers"); 21 | const BYOJS_STORAGE_DIST_DIR = path.join(NODE_MODULES_DIR,"@byojs","storage","dist"); 22 | 23 | const DIST_DIR = path.join(PKG_ROOT_DIR,"dist"); 24 | const DIST_AUTO_DIR = path.join(DIST_DIR,"auto"); 25 | const DIST_AUTO_EXTERNAL_DIR = path.join(DIST_AUTO_DIR,"external"); 26 | const DIST_AUTO_EXTERNAL_LOFI_DIR = path.join(DIST_AUTO_EXTERNAL_DIR,"@lo-fi"); 27 | const DIST_AUTO_EXTERNAL_BYOJS_DIR = path.join(DIST_AUTO_EXTERNAL_DIR,"@byojs"); 28 | const DIST_AUTO_EXTERNAL_LOFI_WALC_DIR = path.join(DIST_AUTO_EXTERNAL_LOFI_DIR,"webauthn-local-client"); 29 | const DIST_AUTO_EXTERNAL_BYOJS_STORAGE_DIR = path.join(DIST_AUTO_EXTERNAL_BYOJS_DIR,"storage"); 30 | const DIST_BUNDLERS_DIR = path.join(DIST_DIR,"bundlers"); 31 | 32 | 33 | main().catch(console.error); 34 | 35 | 36 | // ********************** 37 | 38 | async function main() { 39 | console.log("*** Building JS ***"); 40 | 41 | // try to make various dist/ directories, if needed 42 | for (let dir of [ 43 | DIST_DIR, 44 | DIST_AUTO_DIR, 45 | DIST_AUTO_EXTERNAL_DIR, 46 | DIST_AUTO_EXTERNAL_LOFI_DIR, 47 | DIST_AUTO_EXTERNAL_BYOJS_DIR, 48 | DIST_AUTO_EXTERNAL_LOFI_WALC_DIR, 49 | DIST_AUTO_EXTERNAL_BYOJS_STORAGE_DIR, 50 | DIST_BUNDLERS_DIR, 51 | ]) { 52 | if (!(await safeMkdir(dir))) { 53 | throw new Error(`Target directory (${dir}) does not exist and could not be created.`); 54 | } 55 | } 56 | 57 | // read package.json 58 | var packageJSON = require(path.join(PKG_ROOT_DIR,"package.json")); 59 | // read version number from package.json 60 | var version = packageJSON.version; 61 | // read main src copyright-header text 62 | var mainCopyrightHeader = await fsp.readFile(MAIN_COPYRIGHT_HEADER,{ encoding: "utf8", }); 63 | // render main copyright header with version and year 64 | mainCopyrightHeader = ( 65 | mainCopyrightHeader 66 | .replace(/#VERSION#/g,version) 67 | .replace(/#YEAR#/g,(new Date()).getFullYear()) 68 | ); 69 | 70 | // build src/* files in dist/auto/ 71 | await buildFiles( 72 | recursiveReadDir(SRC_DIR), 73 | SRC_DIR, 74 | DIST_AUTO_DIR, 75 | prepareFileContents, 76 | /*skipPatterns=*/[ "**/*.txt", "**/*.json", "**/external" ] 77 | ); 78 | 79 | // build src/ldl.js to bundlers/ldl.mjs 80 | await buildFiles( 81 | [ LDL_SRC, ], 82 | SRC_DIR, 83 | DIST_BUNDLERS_DIR, 84 | (contents,outputPath,filename = path.basename(outputPath)) => prepareFileContents( 85 | contents, 86 | outputPath.replace(/\.js$/,".mjs"), 87 | `bundlers/${filename.replace(/\.js$/,".mjs")}` 88 | ), 89 | /*skipPatterns=*/[ "**/*.txt", "**/*.json", "**/external" ] 90 | ); 91 | 92 | // build dist/auto/external/* 93 | await buildFiles( 94 | recursiveReadDir(LOFI_WALC_DIST_AUTO_DIR), 95 | LOFI_WALC_DIST_AUTO_DIR, 96 | DIST_AUTO_EXTERNAL_LOFI_WALC_DIR, 97 | // simple copy as-is 98 | (contents,outputPath) => ({ contents, outputPath, }) 99 | ); 100 | await buildFiles( 101 | recursiveReadDir(BYOJS_STORAGE_DIST_DIR), 102 | BYOJS_STORAGE_DIST_DIR, 103 | DIST_AUTO_EXTERNAL_BYOJS_STORAGE_DIR, 104 | // simple copy as-is 105 | (contents,outputPath) => ({ contents, outputPath, }) 106 | ); 107 | 108 | console.log("Complete."); 109 | 110 | 111 | // **************************** 112 | 113 | async function prepareFileContents(contents,outputPath,filename = path.basename(outputPath)) { 114 | // JS file (to minify)? 115 | if (/\.[mc]?js$/i.test(filename)) { 116 | contents = await minifyJS(contents); 117 | } 118 | 119 | // add copyright header 120 | return { 121 | contents: `${ 122 | mainCopyrightHeader.replace(/#FILENAME#/g,filename) 123 | }\n${ 124 | contents 125 | }`, 126 | 127 | outputPath, 128 | }; 129 | } 130 | } 131 | 132 | async function buildFiles(files,fromBasePath,toDir,processFileContents,skipPatterns) { 133 | for (let fromPath of files) { 134 | // should we skip copying this file? 135 | if (matchesSkipPattern(fromPath,skipPatterns)) { 136 | continue; 137 | } 138 | let relativePath = fromPath.slice(fromBasePath.length); 139 | let outputPath = path.join(toDir,relativePath); 140 | let contents = await fsp.readFile(fromPath,{ encoding: "utf8", }); 141 | ({ contents, outputPath, } = await processFileContents(contents,outputPath)); 142 | let outputDir = path.dirname(outputPath); 143 | 144 | if (!(fs.existsSync(outputDir))) { 145 | if (!(await safeMkdir(outputDir))) { 146 | throw new Error(`While copying files, directory (${outputDir}) could not be created.`); 147 | } 148 | } 149 | 150 | await fsp.writeFile(outputPath,contents,{ encoding: "utf8", }); 151 | } 152 | } 153 | 154 | async function minifyJS(contents,esModuleFormat = true) { 155 | let result = await terser.minify(contents,{ 156 | mangle: { 157 | keep_fnames: true, 158 | }, 159 | compress: { 160 | keep_fnames: true, 161 | }, 162 | output: { 163 | comments: /^!/, 164 | }, 165 | module: esModuleFormat, 166 | }); 167 | if (!(result && result.code)) { 168 | if (result.error) throw result.error; 169 | else throw result; 170 | } 171 | return result.code; 172 | } 173 | 174 | function matchesSkipPattern(pathStr,skipPatterns) { 175 | if (skipPatterns && skipPatterns.length > 0) { 176 | return (micromatch(pathStr,skipPatterns).length > 0); 177 | } 178 | } 179 | 180 | async function safeMkdir(pathStr) { 181 | if (!fs.existsSync(pathStr)) { 182 | try { 183 | await fsp.mkdir(pathStr,{ recursive: true, mode: 0o755, }); 184 | return true; 185 | } 186 | catch (err) {} 187 | return false; 188 | } 189 | return true; 190 | } 191 | -------------------------------------------------------------------------------- /scripts/build-gh-pages.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | var fsp = require("fs/promises"); 8 | 9 | var micromatch = require("micromatch"); 10 | var recursiveReadDir = require("recursive-readdir-sync"); 11 | 12 | const PKG_ROOT_DIR = path.join(__dirname,".."); 13 | const DIST_AUTO_DIR = path.join(PKG_ROOT_DIR,"dist","auto"); 14 | const TEST_DIR = path.join(PKG_ROOT_DIR,"test"); 15 | const BUILD_DIR = path.join(PKG_ROOT_DIR,".gh-build"); 16 | const BUILD_DIST_DIR = path.join(BUILD_DIR,"dist"); 17 | const BUILD_DIST_AUTO_DIR = path.join(BUILD_DIST_DIR,"auto"); 18 | 19 | 20 | main().catch(console.error); 21 | 22 | 23 | // ********************** 24 | 25 | async function main() { 26 | console.log("*** Building GH-Pages Deployment ***"); 27 | 28 | // try to make various .gh-build/** directories, if needed 29 | for (let dir of [ BUILD_DIR, BUILD_DIST_DIR, BUILD_DIST_AUTO_DIR, ]) { 30 | if (!(await safeMkdir(dir))) { 31 | throw new Error(`Target directory (${dir}) does not exist and could not be created.`); 32 | } 33 | } 34 | 35 | // copy test/* files 36 | await copyFilesTo( 37 | recursiveReadDir(TEST_DIR), 38 | TEST_DIR, 39 | BUILD_DIR, 40 | /*skipPatterns=*/[ "**/src", "**/dist", ] 41 | ); 42 | 43 | // patch import reference in test.js to point to dist/ 44 | var testJSPath = path.join(BUILD_DIR,"test.js"); 45 | var testJSContents = await fsp.readFile(testJSPath,{ encoding: "utf8", }); 46 | testJSContents = testJSContents.replace(/(from "local-data-lock\/)src"/,"$1dist\""); 47 | await fsp.writeFile(testJSPath,testJSContents,{ encoding: "utf8", }); 48 | 49 | // copy dist/* files 50 | await copyFilesTo( 51 | recursiveReadDir(DIST_AUTO_DIR), 52 | DIST_AUTO_DIR, 53 | BUILD_DIST_AUTO_DIR 54 | ); 55 | 56 | console.log("Complete."); 57 | } 58 | 59 | async function copyFilesTo(files,fromBasePath,toDir,skipPatterns) { 60 | for (let fromPath of files) { 61 | // should we skip copying this file? 62 | if (matchesSkipPattern(fromPath,skipPatterns)) { 63 | continue; 64 | } 65 | 66 | let relativePath = fromPath.slice(fromBasePath.length); 67 | let outputPath = path.join(toDir,relativePath); 68 | let outputDir = path.dirname(outputPath); 69 | 70 | if (!(fs.existsSync(outputDir))) { 71 | if (!(await safeMkdir(outputDir))) { 72 | throw new Error(`While copying files, directory (${outputDir}) could not be created.`); 73 | } 74 | } 75 | 76 | await fsp.copyFile(fromPath,outputPath); 77 | } 78 | } 79 | 80 | function matchesSkipPattern(pathStr,skipPatterns) { 81 | if (skipPatterns && skipPatterns.length > 0) { 82 | return (micromatch(pathStr,skipPatterns).length > 0); 83 | } 84 | } 85 | 86 | async function safeMkdir(pathStr) { 87 | if (!fs.existsSync(pathStr)) { 88 | try { 89 | await fsp.mkdir(pathStr,{ recursive: true, mode: 0o755, }); 90 | return true; 91 | } 92 | catch (err) {} 93 | return false; 94 | } 95 | return true; 96 | } 97 | -------------------------------------------------------------------------------- /scripts/postinstall.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | "use strict"; 4 | 5 | var path = require("path"); 6 | var fs = require("fs"); 7 | 8 | const PKG_ROOT_DIR = path.join(__dirname,".."); 9 | const SRC_DIR = path.join(PKG_ROOT_DIR,"src"); 10 | const TEST_DIR = path.join(PKG_ROOT_DIR,"test"); 11 | 12 | try { fs.symlinkSync(path.join("..","src"),path.join(TEST_DIR,"src"),"dir"); } catch (err) {} 13 | try { fs.symlinkSync(path.join("..","dist"),path.join(TEST_DIR,"dist"),"dir"); } catch (err) {} 14 | -------------------------------------------------------------------------------- /src/copyright-header.txt: -------------------------------------------------------------------------------- 1 | /*! mylofi/Local-Data-Lock: #FILENAME# 2 | v#VERSION# (c) #YEAR# Kyle Simpson 3 | MIT License: http://getify.mit-license.org 4 | */ 5 | -------------------------------------------------------------------------------- /src/ldl.js: -------------------------------------------------------------------------------- 1 | import { 2 | supportsWebAuthn, 3 | regDefaults, 4 | register, 5 | authDefaults, 6 | auth, 7 | verifyAuthResponse, 8 | packPublicKeyJSON, 9 | unpackPublicKeyJSON, 10 | toBase64String, 11 | fromBase64String, 12 | toUTF8String, 13 | fromUTF8String, 14 | resetAbortReason, 15 | } from "@lo-fi/webauthn-local-client"; 16 | 17 | 18 | // *********************** 19 | 20 | const CURRENT_LOCK_KEY_FORMAT_VERSION = 1; 21 | const IV_BYTE_LENGTH = sodium.crypto_sign_SEEDBYTES; 22 | const supportsWAUserVerification = ( 23 | typeof PublicKeyCredential != "undefined" && 24 | typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable != "undefined" && 25 | 26 | // NOTE: top-level await (requires ES2022+) 27 | (await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()) 28 | ); 29 | var store = null; 30 | var localIdentities = null; 31 | var lockKeyCache = {}; 32 | var abortToken = null; 33 | var externalSignalCache = new WeakMap(); 34 | var cachePurgeIntv = null; 35 | var LOCK_KEY_CACHE_LIFETIME = setLockKeyCacheLifetime(30 * 60 * 1000); // 30 min (default) 36 | var DEFAULT_STORAGE_TYPE = "idb"; 37 | 38 | // attempt to ensure that webauthn passkey assertions 39 | // are never silent and always user-verified (UV) 40 | if (supportsWAUserVerification) { 41 | navigator.credentials.preventSilentAccess(); 42 | } 43 | 44 | 45 | // *********************** 46 | 47 | export { 48 | // re-export WebAuthn-Local-Client helper utilities: 49 | supportsWebAuthn, 50 | packPublicKeyJSON, 51 | unpackPublicKeyJSON, 52 | toBase64String, 53 | fromBase64String, 54 | toUTF8String, 55 | fromUTF8String, 56 | resetAbortReason, 57 | 58 | // main library API: 59 | supportsWAUserVerification, 60 | listLocalIdentities, 61 | cacheLockKey, 62 | clearLockKeyCache, 63 | removeLocalAccount, 64 | getLockKey, 65 | generateEntropy, 66 | deriveLockKey, 67 | lockData, 68 | unlockData, 69 | signData, 70 | verifySignature, 71 | configure, 72 | }; 73 | var publicAPI = { 74 | // re-export WebAuthn-Local-Client helper utilities: 75 | supportsWebAuthn, 76 | packPublicKeyJSON, 77 | unpackPublicKeyJSON, 78 | toBase64String, 79 | fromBase64String, 80 | toUTF8String, 81 | fromUTF8String, 82 | resetAbortReason, 83 | 84 | // main library API: 85 | supportsWAUserVerification, 86 | listLocalIdentities, 87 | cacheLockKey, 88 | clearLockKeyCache, 89 | removeLocalAccount, 90 | getLockKey, 91 | generateEntropy, 92 | deriveLockKey, 93 | lockData, 94 | unlockData, 95 | signData, 96 | verifySignature, 97 | configure, 98 | }; 99 | export default publicAPI; 100 | 101 | 102 | // *********************** 103 | 104 | async function listLocalIdentities() { 105 | await checkStorage(); 106 | return Object.keys(localIdentities); 107 | } 108 | 109 | function getCachedLockKey(localID) { 110 | var now = Date.now(); 111 | 112 | if ( 113 | // lock-key currently in cache? 114 | (localID in lockKeyCache) && 115 | 116 | // ... and not expired yet? 117 | lockKeyCache[localID].timestamp >= ( 118 | now - Math.min(LOCK_KEY_CACHE_LIFETIME,now) 119 | ) 120 | ) { 121 | // hide cache-internal timestamp field 122 | let { timestamp, ...lockKey } = lockKeyCache[localID]; 123 | return lockKey; 124 | } 125 | } 126 | 127 | function cacheLockKey(lockKey) { 128 | internalCacheLockKey(lockKey.localIdentity,checkLockKey(lockKey)); 129 | } 130 | 131 | function internalCacheLockKey(localID,lockKey,forceUpdate = false) { 132 | if (!(localID in lockKeyCache) || forceUpdate) { 133 | lockKeyCache[localID] = { 134 | ...lockKey, 135 | 136 | // cache-internal timestamp field, for recency 137 | // expiration check 138 | timestamp: Date.now(), 139 | }; 140 | resetCachePurgeTimer(); 141 | } 142 | } 143 | 144 | function clearLockKeyCache(localID) { 145 | if (localID != null) { 146 | delete lockKeyCache[localID]; 147 | } 148 | else { 149 | lockKeyCache = {}; 150 | } 151 | resetCachePurgeTimer(); 152 | } 153 | 154 | function resetCachePurgeTimer() { 155 | if (cachePurgeIntv != null) { 156 | clearTimeout(cachePurgeIntv); 157 | cachePurgeIntv = null; 158 | } 159 | setCachePurgeTimer(); 160 | } 161 | 162 | function setCachePurgeTimer() { 163 | if (cachePurgeIntv == null) { 164 | let nextTimestamp = nextCacheTimestamp(); 165 | if (nextTimestamp != null) { 166 | let when = Math.max( 167 | // at least 1 minute 168 | 60_000, 169 | ( 170 | // time left until expiration (if any) 171 | Math.max(nextTimestamp + LOCK_KEY_CACHE_LIFETIME - Date.now(),0) + 172 | 173 | // up to 10 more seconds 174 | Math.round(Math.random() * 1E4) 175 | ) 176 | ); 177 | cachePurgeIntv = setTimeout(purgeExpiredCacheEntries,when); 178 | } 179 | } 180 | } 181 | 182 | function nextCacheTimestamp() { 183 | return (( 184 | Object.values(lockKeyCache).map(entry => entry.timestamp) 185 | ) || [])[0]; 186 | } 187 | 188 | function purgeExpiredCacheEntries() { 189 | cachePurgeIntv = null; 190 | var now = Date.now(); 191 | var when = now - Math.min(LOCK_KEY_CACHE_LIFETIME,now); 192 | 193 | Object.entries(lockKeyCache) 194 | .filter(([ localID, entry, ]) => entry.timestamp < when) 195 | .forEach(([ localID, ]) => { delete lockKeyCache[localID]; }); 196 | 197 | resetCachePurgeTimer(); 198 | } 199 | 200 | async function removeLocalAccount(localID) { 201 | await checkStorage(); 202 | delete lockKeyCache[localID]; 203 | delete localIdentities[localID]; 204 | return storeLocalIdentities(); 205 | } 206 | 207 | async function getLockKey( 208 | { 209 | localIdentity: localID = toBase64String(generateEntropy(15)), 210 | username = "local-user", 211 | displayName = "Local User", 212 | relyingPartyID = document.location.hostname, 213 | relyingPartyName = "Local Data Lock", 214 | addNewPasskey = false, 215 | resetLockKey = false, 216 | useLockKey = null, 217 | verify = true, 218 | regOverride: { 219 | authenticatorSelection: { 220 | authenticatorAttachment: regAuthenticatorAttachment = "platform", 221 | userVerification: regUserVerification = "required", 222 | residentKey: regResidentKey = "required", 223 | requireResidentKey: regRequireResidentKey = true, 224 | ...regOtherAuthenticatorSelectionParams 225 | } = {}, 226 | extensions: { 227 | crepProps: regCredProps = true, 228 | credentialProtectionPolicy: regCredentialProtectionPolicy = "userVerificationRequired", 229 | ...regOtherExtensionsParams 230 | } = {}, 231 | ...regOtherParams 232 | } = {}, 233 | authOverride: { 234 | mediation: authMediation = "required", 235 | userVerification: authUserVerification = "required", 236 | ...authOtherParams 237 | } = {}, 238 | signal: cancelLockKey, 239 | } = {}, 240 | ) { 241 | if (!supportsWAUserVerification) { 242 | throw new Error("Required user verification not supported with any authenticator on this device."); 243 | } 244 | 245 | // local-identity already registered? 246 | await checkStorage(); 247 | var identityRecord = localID != null ? localIdentities[localID] : null; 248 | if (identityRecord != null) { 249 | // lock-key already in cache? 250 | let lockKey = getCachedLockKey(localID); 251 | if (lockKey != null && !resetLockKey) { 252 | if (addNewPasskey) { 253 | resetAbortToken(cancelLockKey); 254 | 255 | let { record, } = (await registerLocalIdentity(lockKey)) || {}; 256 | 257 | cleanupExternalSignalHandler(abortToken); 258 | abortToken = null; 259 | 260 | // new passkey registration succeeded? 261 | if (record != null) { 262 | identityRecord.lastSeq = record.lastSeq; 263 | identityRecord.passkeys = [ 264 | ...identityRecord.passkeys, 265 | ...record.passkeys 266 | ]; 267 | await storeLocalIdentities(); 268 | } 269 | } 270 | 271 | // return cached lock-key info 272 | return Object.freeze({ 273 | ...lockKey, 274 | localIdentity: localID, 275 | }); 276 | } 277 | else { 278 | // remove expired cache entry (if any) 279 | delete lockKeyCache[localID]; 280 | 281 | // create (or import) new lock-key (and passkey)? 282 | if (resetLockKey) { 283 | resetAbortToken(cancelLockKey); 284 | 285 | // throw away previous identity record (including 286 | // previous passkeys) and replace with this new 287 | // identity record and passkey 288 | ({ 289 | record: localIdentities[localID], 290 | lockKey, 291 | } = (await registerLocalIdentity( 292 | // manually importing an external lock-key? 293 | useLockKey && typeof useLockKey == "object" ? 294 | checkLockKey(useLockKey) : 295 | undefined 296 | ))) || {}; 297 | 298 | cleanupExternalSignalHandler(abortToken); 299 | abortToken = null; 300 | 301 | // registration failed? 302 | if (localIdentities[localID] == null) { 303 | delete localIdentities[localID]; 304 | } 305 | // registration succeeded, lock-key returned? 306 | else if (lockKey != null) { 307 | await storeLocalIdentities(); 308 | internalCacheLockKey(localID,lockKey); 309 | 310 | return Object.freeze({ 311 | ...lockKey, 312 | localIdentity: localID, 313 | }); 314 | } 315 | } 316 | // auth with existing passkey (and cache resulting lock-key)? 317 | else if (!addNewPasskey) { 318 | resetAbortToken(cancelLockKey); 319 | 320 | let authOptions = authDefaults({ 321 | relyingPartyID, 322 | allowCredentials: ( 323 | identityRecord.passkeys.map(({ credentialID, }) => ({ 324 | type: "public-key", 325 | id: credentialID, 326 | })) 327 | ), 328 | mediation: authMediation, 329 | userVerification: authUserVerification, 330 | signal: abortToken.signal, 331 | ...authOtherParams, 332 | }); 333 | let authResult = await auth(authOptions); 334 | 335 | if (!verifyCredentialSecurity(authResult.response)) { 336 | throw new Error("Authentication response insufficient",{ cause: authResult, }); 337 | } 338 | 339 | cleanupExternalSignalHandler(abortToken); 340 | abortToken = null; 341 | 342 | // authentication succeeded? 343 | if (authResult != null) { 344 | // verify auth result? 345 | if (verify) { 346 | let passkey = identityRecord.passkeys.find(passkey => ( 347 | passkey.credentialID == authResult.response.credentialID 348 | )); 349 | let publicKey = passkey?.publicKey; 350 | let verified = ( 351 | publicKey != null ? 352 | (await verifyAuthResponse(authResult.response,publicKey)) : 353 | false 354 | ); 355 | if (!verified) { 356 | throw new Error("Auth verification failed"); 357 | } 358 | } 359 | 360 | return { 361 | ...extractLockKey(authResult), 362 | localIdentity: localID, 363 | }; 364 | } 365 | } 366 | else { 367 | throw new Error("Encryption/Decryption key not currently cached, unavailable for new passkey"); 368 | } 369 | } 370 | } 371 | // attempt auth (with existing discoverable passkey) to extract 372 | // (and cache!) existing lock-key? 373 | else if (!addNewPasskey) { 374 | resetAbortToken(cancelLockKey); 375 | let authOptions = authDefaults({ 376 | relyingPartyID, 377 | mediation: authMediation, 378 | userVerification: authUserVerification, 379 | signal: abortToken.signal, 380 | ...authOtherParams, 381 | }); 382 | let authResult = await auth(authOptions); 383 | 384 | if (!verifyCredentialSecurity(authResult.response)) { 385 | throw new Error("Authentication response insufficient",{ cause: authResult, }); 386 | } 387 | 388 | cleanupExternalSignalHandler(abortToken); 389 | abortToken = null; 390 | 391 | // authentication succeeded? 392 | if (authResult != null) { 393 | let lockKey = extractLockKey(authResult); 394 | 395 | // find matching local-identity (if any) 396 | let [ matchingLocalID, ] = ( 397 | Object.entries(localIdentities) 398 | .find(([ , record, ]) => ( 399 | record.passkeys.find(passkey => ( 400 | // matching credential used for authentication? 401 | passkey.credentialID == authResult.response.credentialID 402 | )) != null 403 | )) 404 | ) || []; 405 | // discard auto-generated local-id, use matching local-id? 406 | if (matchingLocalID != null) { 407 | delete lockKeyCache[localID]; 408 | localID = matchingLocalID; 409 | identityRecord = localIdentities[localID]; 410 | 411 | // verify auth result? 412 | if (verify) { 413 | let passkey = identityRecord.passkeys.find(passkey => ( 414 | passkey.credentialID == authResult.response.credentialID 415 | )); 416 | let publicKey = passkey?.publicKey; 417 | let verified = ( 418 | publicKey != null ? 419 | (await verifyAuthResponse(authResult.response,publicKey)) : 420 | false 421 | ); 422 | if (!verified) { 423 | throw new Error("Auth verification failed"); 424 | } 425 | } 426 | 427 | internalCacheLockKey(localID,lockKey); 428 | } 429 | else if (verify) { 430 | throw new Error("Auth verification requested but skipped, against unrecognized passkey (no matching local-identity)"); 431 | } 432 | 433 | return Object.freeze({ 434 | ...lockKey, 435 | localIdentity: localID, 436 | }); 437 | } 438 | } 439 | // new local-identity needs initial registration 440 | else { 441 | resetAbortToken(cancelLockKey); 442 | let { record, lockKey, } = (await registerLocalIdentity( 443 | // manually importing an external lock-key? 444 | useLockKey && typeof useLockKey == "object" ? 445 | checkLockKey(useLockKey) : 446 | undefined 447 | )) || {}; 448 | 449 | cleanupExternalSignalHandler(abortToken); 450 | abortToken = null; 451 | 452 | // registration succeeded, lock-key returned? 453 | if (record != null && lockKey != null) { 454 | localIdentities[localID] = record; 455 | internalCacheLockKey(localID,lockKey); 456 | await storeLocalIdentities(); 457 | 458 | return Object.freeze({ 459 | ...lockKey, 460 | localIdentity: localID, 461 | }); 462 | } 463 | } 464 | 465 | 466 | // *********************** 467 | 468 | async function registerLocalIdentity(lockKey = deriveLockKey()) { 469 | try { 470 | let identityRecord = localIdentities[localID]; 471 | let lastSeq = ((identityRecord || {}).lastSeq || 0) + 1; 472 | 473 | // note: encode the userHandle field of the passkey with the 474 | // first 32 bytes of the keypair IV, and then 2 bytes 475 | // to encode (big-endian) a passkey sequence value; this 476 | // additional value allows multiple passkeys (up to 65,535 of 477 | // them) registered on the same authenticator, sharing the same 478 | // lock-keypair IV in its userHandle 479 | let userHandle = new Uint8Array(lockKey.iv.byteLength + 2); 480 | let seqBytes = new DataView(new ArrayBuffer(2)); 481 | seqBytes.setInt16(0,lastSeq,/*littleEndian=*/false); 482 | userHandle.set(lockKey.iv,0); 483 | userHandle.set(new Uint8Array(seqBytes.buffer),lockKey.iv.byteLength); 484 | 485 | let regOptions = regDefaults({ 486 | authenticatorSelection: { 487 | authenticatorAttachment: regAuthenticatorAttachment, 488 | userVerification: regUserVerification, 489 | residentKey: regResidentKey, 490 | requireResidentKey: regRequireResidentKey, 491 | ...regOtherAuthenticatorSelectionParams, 492 | }, 493 | relyingPartyID, 494 | relyingPartyName, 495 | user: { 496 | id: userHandle, 497 | name: username, 498 | displayName, 499 | }, 500 | extensions: { 501 | credProps: regCredProps, 502 | credentialProtectionPolicy: regCredentialProtectionPolicy, 503 | ...regOtherExtensionsParams, 504 | }, 505 | signal: abortToken.signal, 506 | ...regOtherParams, 507 | }); 508 | let regResult = await register(regOptions); 509 | 510 | if ( 511 | regResult != null && 512 | 513 | ( 514 | // intentionally modified the default registration 515 | // credential security settings? 516 | !( 517 | regAuthenticatorAttachment == "platform" && 518 | regUserVerification == "required" && 519 | regResidentKey == "required" && 520 | regRequireResidentKey === true && 521 | regCredProps === true && 522 | regCredentialProtectionPolicy == "userVerificationRequired" 523 | ) || 524 | 525 | // otherwise, assume default registration credential 526 | // security settings 527 | // 528 | // verify the authenticator respected our request 529 | // to ensure user-presence and user-verification 530 | verifyCredentialSecurity(regResult.response) 531 | ) 532 | ) { 533 | return { 534 | record: { 535 | lastSeq, 536 | passkeys: [ 537 | buildPasskeyEntry({ 538 | seq: lastSeq, 539 | credentialID: regResult.response.credentialID, 540 | publicKey: regResult.response.publicKey, 541 | }), 542 | ], 543 | }, 544 | lockKey, 545 | }; 546 | } 547 | 548 | throw new Error("Registration response insufficient",{ cause: regResult, }); 549 | } 550 | catch (err) { 551 | throw new Error("Identity/Passkey registration failed",{ cause: err, }); 552 | } 553 | } 554 | 555 | function verifyCredentialSecurity(response) { 556 | var extensionData = ( 557 | ( 558 | response.extensionData != null || 559 | response.clientExtensionData != null 560 | ) ? 561 | Object.assign( 562 | {}, 563 | response.extensionData, 564 | response.clientExtensionData 565 | ) : 566 | 567 | null 568 | ); 569 | return ( 570 | response.flags != null && 571 | response.flags.userPresence === true && 572 | response.flags.userVerification === true && 573 | ( 574 | extensionData == null || 575 | 576 | // note: extension only passed for registration 577 | ( 578 | extensionData.credProps != null && 579 | extensionData.credProps.rk === true 580 | ) 581 | ) 582 | ); 583 | } 584 | 585 | function extractLockKey(authResult) { 586 | try { 587 | if ( 588 | authResult && 589 | authResult.response && 590 | isByteArray(authResult.response.userID) && 591 | authResult.response.userID.byteLength == (IV_BYTE_LENGTH + 2) 592 | ) { 593 | let lockKey = deriveLockKey( 594 | authResult.response.userID.slice(0,IV_BYTE_LENGTH) 595 | ); 596 | internalCacheLockKey(localID,lockKey); 597 | return lockKey; 598 | } 599 | else { 600 | throw new Error("Passkey info missing"); 601 | } 602 | } 603 | catch (err) { 604 | throw new Error("Chosen passkey did not provide a valid encryption/decryption key",{ cause: err, }); 605 | } 606 | } 607 | } 608 | 609 | function resetAbortToken(externalSignal) { 610 | // previous attempt still pending? 611 | if (abortToken != null) { 612 | cleanupExternalSignalHandler(abortToken); 613 | 614 | if (!abortToken.aborted) { 615 | abortToken.abort("Passkey operation abandoned."); 616 | } 617 | } 618 | abortToken = new AbortController(); 619 | 620 | // new external abort-signal passed in, to chain 621 | // off of? 622 | if (externalSignal != null) { 623 | // signal already aborted? 624 | if (externalSignal.aborted) { 625 | abortToken.abort(externalSignal.reason); 626 | } 627 | // listen to future abort-signal 628 | else { 629 | let handlerFn = () => { 630 | cleanupExternalSignalHandler(abortToken); 631 | abortToken.abort(externalSignal.reason); 632 | abortToken = externalSignal = handlerFn = null; 633 | }; 634 | externalSignal.addEventListener("abort",handlerFn); 635 | externalSignalCache.set(abortToken,[ externalSignal, handlerFn, ]); 636 | } 637 | } 638 | } 639 | 640 | function cleanupExternalSignalHandler(token) { 641 | // controller previously attached to an 642 | // external abort-signal? 643 | if (token != null && externalSignalCache.has(token)) { 644 | let [ prevExternalSignal, handlerFn, ] = externalSignalCache.get(token); 645 | prevExternalSignal.removeEventListener("abort",handlerFn); 646 | externalSignalCache.delete(token); 647 | } 648 | } 649 | 650 | function generateEntropy(numBytes = 16) { 651 | return sodium.randombytes_buf(numBytes); 652 | } 653 | 654 | function deriveLockKey(iv = generateEntropy(IV_BYTE_LENGTH)) { 655 | try { 656 | let ed25519KeyPair = sodium.crypto_sign_seed_keypair(iv); 657 | return { 658 | keyFormatVersion: CURRENT_LOCK_KEY_FORMAT_VERSION, 659 | iv, 660 | publicKey: ed25519KeyPair.publicKey, 661 | privateKey: ed25519KeyPair.privateKey, 662 | encPK: sodium.crypto_sign_ed25519_pk_to_curve25519( 663 | ed25519KeyPair.publicKey, 664 | ), 665 | encSK: sodium.crypto_sign_ed25519_sk_to_curve25519( 666 | ed25519KeyPair.privateKey, 667 | ), 668 | }; 669 | } 670 | catch (err) { 671 | throw new Error("Encryption/decryption key derivation failed.",{ cause: err, }); 672 | } 673 | } 674 | 675 | function checkLockKey(lockKeyCandidate) { 676 | if ( 677 | lockKeyCandidate && 678 | typeof lockKeyCandidate == "object" 679 | ) { 680 | // assume current format key? 681 | if (lockKeyCandidate.keyFormatVersion === CURRENT_LOCK_KEY_FORMAT_VERSION) { 682 | return lockKeyCandidate; 683 | } 684 | // contains a suitable `iv` we can derive from? 685 | else if ( 686 | isByteArray(lockKeyCandidate.iv) && 687 | lockKeyCandidate.iv.byteLength == IV_BYTE_LENGTH 688 | ) { 689 | return { 690 | localIdentity: lockKeyCandidate.localIdentity, 691 | ...deriveLockKey(lockKeyCandidate.iv), 692 | }; 693 | } 694 | } 695 | throw new Error("Unrecongnized lock-key"); 696 | } 697 | 698 | function lockData( 699 | data, 700 | lockKey, 701 | /*options=*/{ 702 | outputFormat = "base64", // "base64", "raw" 703 | } = {} 704 | ) { 705 | try { 706 | let dataBuffer = dataToBuffer(data); 707 | let encData = sodium.crypto_box_seal(dataBuffer,lockKey.encPK); 708 | return ( 709 | [ "base64", "base-64", ].includes(outputFormat.toLowerCase()) ? 710 | toBase64String(encData) : 711 | encData 712 | ); 713 | } 714 | catch (err) { 715 | throw new Error("Data encryption failed.",{ cause: err, }); 716 | } 717 | } 718 | 719 | function unlockData( 720 | encData, 721 | lockKey, 722 | /*options=*/{ 723 | outputFormat = "utf8", // "utf8", "raw" 724 | parseJSON = true, 725 | } = {} 726 | ) { 727 | try { 728 | let dataBuffer = sodium.crypto_box_seal_open( 729 | ( 730 | typeof encData == "string" ? fromBase64String(encData) : 731 | encData 732 | ), 733 | lockKey.encPK, 734 | lockKey.encSK 735 | ); 736 | 737 | if ([ "utf8", "utf-8", ].includes(outputFormat.toLowerCase())) { 738 | let decodedData = toUTF8String(dataBuffer); 739 | return ( 740 | parseJSON ? JSON.parse(decodedData) : decodedData 741 | ); 742 | } 743 | else { 744 | return dataBuffer; 745 | } 746 | } 747 | catch (err) { 748 | throw new Error("Data decryption failed.",{ cause: err, }); 749 | } 750 | } 751 | 752 | function signData( 753 | data, 754 | /*lockKey=*/{ 755 | privateKey, 756 | } = {}, 757 | /*options=*/{ 758 | outputFormat = "base64", // "base64", "raw" 759 | } = {} 760 | ) { 761 | try { 762 | let signatureBuffer = sodium.crypto_sign_detached( 763 | dataToBuffer(data), 764 | privateKey 765 | ); 766 | return ( 767 | [ "base64", "base-64", ].includes(outputFormat.toLowerCase()) ? 768 | toBase64String(signatureBuffer) : 769 | signatureBuffer 770 | ); 771 | } 772 | catch (err) { 773 | throw new Error("Data signature failed.",{ cause: err, }); 774 | } 775 | } 776 | 777 | function verifySignature( 778 | data, 779 | /*lockKey=*/{ 780 | publicKey, 781 | } = {}, 782 | signature 783 | ) { 784 | try { 785 | return sodium.crypto_sign_verify_detached( 786 | ( 787 | typeof signature == "string" ? fromBase64String(signature) : 788 | signature 789 | ), 790 | dataToBuffer(data), 791 | publicKey 792 | ); 793 | } 794 | catch (err) { 795 | throw new Error("Data signature failed.",{ cause: err, }); 796 | } 797 | } 798 | 799 | async function loadLocalIdentities() { 800 | return ( 801 | Object.fromEntries( 802 | Object.entries( 803 | (await store.get("local-identities")) || {} 804 | ) 805 | // only accept well-formed local-identity entries 806 | .filter(([ localID, entry, ]) => ( 807 | typeof entry.lastSeq == "number" && 808 | Array.isArray(entry.passkeys) && 809 | entry.passkeys.length > 0 && 810 | entry.passkeys.every(passkey => ( 811 | typeof passkey.credentialID == "string" && 812 | passkey.credentialID != "" && 813 | typeof passkey.seq == "number" && 814 | passkey.publicKey != null && 815 | typeof passkey.publicKey == "object" && 816 | typeof passkey.publicKey.algoCOSE == "number" && 817 | typeof passkey.publicKey.raw == "string" && 818 | passkey.publicKey.raw != "" && 819 | typeof passkey.publicKey.spki == "string" && 820 | passkey.publicKey.spki != "" && 821 | typeof passkey.hash == "string" && 822 | passkey.hash != "" && 823 | passkey.hash == computePasskeyEntryHash(passkey) 824 | )) 825 | )) 826 | // unpack passkey public-keys 827 | .map(([ localID, entry, ]) => ([ 828 | localID, 829 | { 830 | ...entry, 831 | passkeys: entry.passkeys.map(passkey => ({ 832 | ...passkey, 833 | publicKey: unpackPublicKeyJSON(passkey.publicKey), 834 | })) 835 | }, 836 | ])) 837 | ) 838 | ); 839 | } 840 | 841 | async function storeLocalIdentities() { 842 | await checkStorage(); 843 | 844 | var identities = Object.fromEntries( 845 | Object.entries(localIdentities) 846 | .map(([ localID, entry, ]) => ([ 847 | localID, 848 | { 849 | ...entry, 850 | passkeys: entry.passkeys.map(passkey => ({ 851 | ...passkey, 852 | publicKey: packPublicKeyJSON(passkey.publicKey), 853 | })) 854 | }, 855 | ])) 856 | ); 857 | 858 | if (Object.keys(identities).length > 0) { 859 | await store.set("local-identities",identities); 860 | } 861 | else { 862 | await store.remove("local-identities"); 863 | } 864 | } 865 | 866 | function setLockKeyCacheLifetime(ms) { 867 | try { 868 | return (LOCK_KEY_CACHE_LIFETIME = Math.max(0,Number(ms) || 0)); 869 | } 870 | finally { 871 | resetCachePurgeTimer(); 872 | } 873 | } 874 | 875 | function configureStorage(storageOpt) { 876 | if ( 877 | // known storage adapter type? 878 | [ "idb", "local-storage", "session-storage", "cookie", "opfs", "opfs-worker", ] 879 | .includes(storageOpt) 880 | ) { 881 | DEFAULT_STORAGE_TYPE = storageOpt; 882 | 883 | // ensure next access pulls fresh from storage 884 | localIdentities = store = null; 885 | } 886 | else if ( 887 | // raw storage adapter passed in directly? 888 | typeof storageOpt == "object" && 889 | 890 | // has conforming API? 891 | typeof storageOpt.storageType == "string" && 892 | 893 | [ "has", "get", "set", "remove", "keys", "entries", ].every(method => ( 894 | typeof storageOpt[method] == "function" 895 | )) 896 | ) { 897 | store = storageOpt; 898 | DEFAULT_STORAGE_TYPE = storageOpt.storageType; 899 | 900 | // ensure next access pulls fresh from storage 901 | localIdentities = null; 902 | } 903 | else { 904 | throw new Error(`Unrecognized storage type ('${storageType}')`); 905 | } 906 | } 907 | 908 | function configure({ 909 | accountStorage, 910 | cacheLifetime, 911 | } = {}) { 912 | if (accountStorage != null) { 913 | configureStorage(accountStorage); 914 | } 915 | if (cacheLifetime != null) { 916 | setLockKeyCacheLifetime(cacheLifetime) 917 | } 918 | } 919 | 920 | function dataToBuffer(data) { 921 | var dataBuffer = ( 922 | // null/undefined? 923 | data == null ? null : 924 | 925 | // raw array buffer? 926 | data instanceof ArrayBuffer ? new Uint8Array(data) : 927 | 928 | // already uint8 byte array? 929 | isByteArray(data) ? data : 930 | 931 | // encode text to uint8 buffer 932 | fromUTF8String( 933 | // JSON-compatible object (hopefully)? 934 | typeof data == "object" ? JSON.stringify(data) : 935 | 936 | // already a string? 937 | typeof data == "string" ? data : 938 | 939 | // some other value type that needs to be 940 | // stringified 941 | String(data) 942 | ) 943 | ); 944 | if (dataBuffer == null) { 945 | throw new Error("Non-empty data required."); 946 | } 947 | return dataBuffer; 948 | } 949 | 950 | function isByteArray(val) { 951 | return ( 952 | val instanceof Uint8Array && val.buffer instanceof ArrayBuffer 953 | ); 954 | } 955 | 956 | function buildPasskeyEntry(passkey) { 957 | return { 958 | ...passkey, 959 | hash: computePasskeyEntryHash(passkey), 960 | }; 961 | } 962 | 963 | function computePasskeyEntryHash(passkeyEntry) { 964 | let { hash: _, ...passkey } = passkeyEntry; 965 | return toBase64String(sodium.crypto_hash(JSON.stringify({ 966 | ...passkey, 967 | publicKey: packPublicKeyJSON(passkey.publicKey), 968 | }))); 969 | } 970 | 971 | async function checkStorage() { 972 | if (store == null) { 973 | if ( 974 | [ "idb", "local-storage", "session-storage", "cookie", "opfs", "opfs-worker", ] 975 | .includes(DEFAULT_STORAGE_TYPE) 976 | ) { 977 | // note: bundlers (e.g., Astro, etc) don't support transforming 978 | // import-modifiers unless they're fixed strings, so that's why 979 | // we're using a ternary here instead of interpolating the value 980 | store = ( 981 | DEFAULT_STORAGE_TYPE == "idb" ? 982 | await import("@byojs/storage/idb") : 983 | DEFAULT_STORAGE_TYPE == "local-storage" ? 984 | await import("@byojs/storage/local-storage") : 985 | DEFAULT_STORAGE_TYPE == "session-storage" ? 986 | await import("@byojs/storage/session-storage") : 987 | DEFAULT_STORAGE_TYPE == "cookie" ? 988 | await import("@byojs/storage/cookie") : 989 | DEFAULT_STORAGE_TYPE == "opfs" ? 990 | await import("@byojs/storage/opfs") : 991 | DEFAULT_STORAGE_TYPE == "opfs-worker" ? 992 | await import("@byojs/storage/opfs-worker") : 993 | 994 | // note: won't ever get here 995 | null 996 | ); 997 | } 998 | // note: shouldn't ever get here 999 | else { 1000 | throw new Error(`Unrecognized storage type ('${DEFAULT_STORAGE_TYPE}')`); 1001 | } 1002 | } 1003 | if (store != null && localIdentities == null) { 1004 | localIdentities = await loadLocalIdentities(); 1005 | } 1006 | } 1007 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Local Data Lock: Tests 7 | 17 | 18 | 19 | 20 |
21 |

Local Data Lock: Tests

22 | 23 |

Github

24 | 25 |
26 | 27 |

Note: these tests require a passkey-enabled device (TouchID, FaceID, etc); also, the data entered is saved (encrypted!) only in the session-storage on this device, which can be inspected via this browser's developer-tools.

28 | 29 |

Steps To Run Tests:

30 |
    31 |
  1. Register a local account (providing a username and display-name for the first passkey on the account).
  2. 32 |
  3. Register another local account (providing a different username and display name for the passkey on that second account).
  4. 33 |
  5. Select one of the accounts from the drop-down list, and click the 'unlock account' button.
  6. 34 |
  7. Type some text into the box, and click the 'save' button.
  8. 35 |
  9. Click the 'sign & verify' button, then click the 'Sign' button (after modifying the text, if you prefer), and look for "Verified!" under the signature. Close the dialog.
  10. 36 |
  11. Click the 'lock account' button.
  12. 37 |
  13. Click the 'detect (and unlock) account' button; you will be prompted to choose one of the passkeys for one of the registered local accounts.
  14. 38 |
  15. Click 'add passkey' and provide yet another username and display-name for the additional passkey on the currently selected account.
  16. 39 |
  17. Change the 'Passkey Keep-Alive' value to 1 minute, and click the 'set' button.
  18. 40 |
  19. Wait at least 1 minute, then enter (or change) some text, and click 'save'; you will be prompted to re-authenticate a registered passkey.
  20. 41 |
  21. While logged into both accounts, you will be able to switch between them (using the dropdown and the 'login to account' button), and update the text for each account and click 'save' button... all WITHOUT being re-prompted for any passkeys; once the 1 minute has expired, you'll be prompted for the passkey at the first interaction with each account. 42 |
  22. Click the 'reset account' button; you will be prompted to create a new passkey for the current account (previous passkeys will be discarded).
  23. 43 |
  24. Change the passkey timeout from 0 to 5 (seconds).
  25. 44 |
  26. Click 'lock account', then 'unlock account'. Wait for at least 5 seconds, and see the authentication dialog be canceled/closed and an error message displayed.
  27. 45 |
46 | 47 |

When complete with testing:

48 | 49 |
    50 |
  1. Click the 'reset (remove all accounts)' button.
  2. 51 |
  3. Use the device's system management settings to remove all the passkeys registered during testing.
  4. 52 |
53 | 54 |
55 | 56 |
57 |
58 | 62 | 63 |
64 |
65 | 69 | (0 to disable) 70 |
71 |
72 |

73 | 74 | 75 | 76 |

77 |

78 | 81 | 82 | 83 |

84 |

85 | 86 | 87 | 88 |

89 |

90 | 91 |

92 |

93 | 94 |

95 | 96 |
97 | 98 | 99 | 100 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /test/spinner.js: -------------------------------------------------------------------------------- 1 | var spinnerStart = Scheduler(300,400); 2 | var spinnerCancel; 3 | 4 | 5 | // *********************** 6 | 7 | export { startSpinner, stopSpinner, }; 8 | 9 | 10 | // *********************** 11 | 12 | function startSpinner() { 13 | if (!spinnerCancel) { 14 | spinnerCancel = spinnerStart(showSpinner); 15 | } 16 | } 17 | 18 | function showSpinner() { 19 | Swal.fire({ 20 | position: "top", 21 | showConfirmButton: false, 22 | allowOutsideClick: false, 23 | allowEscapeKey: false, 24 | customClass: { 25 | popup: "spinner-popup", 26 | }, 27 | }); 28 | Swal.showLoading(); 29 | } 30 | 31 | function stopSpinner() { 32 | if (spinnerCancel) { 33 | spinnerCancel(); 34 | spinnerCancel = null; 35 | if (Swal.isVisible() && Swal.getPopup().matches(".spinner-popup")) { 36 | return Swal.close(); 37 | } 38 | } 39 | } 40 | 41 | function Scheduler(debounceMin,throttleMax) { 42 | var entries = new WeakMap(); 43 | 44 | return schedule; 45 | 46 | 47 | // *********************** 48 | 49 | function schedule(fn) { 50 | var entry; 51 | 52 | if (entries.has(fn)) { 53 | entry = entries.get(fn); 54 | } 55 | else { 56 | entry = { 57 | last: 0, 58 | timer: null, 59 | }; 60 | entries.set(fn,entry); 61 | } 62 | 63 | var now = Date.now(); 64 | 65 | if (!entry.timer) { 66 | entry.last = now; 67 | } 68 | 69 | if ( 70 | // no timer running yet? 71 | entry.timer == null || 72 | // room left to debounce while still under the throttle-max? 73 | (now - entry.last) < throttleMax 74 | ) { 75 | if (entry.timer) { 76 | clearTimeout(entry.timer); 77 | } 78 | 79 | let time = Math.min(debounceMin,Math.max(0,(entry.last + throttleMax) - now)); 80 | entry.timer = setTimeout(run,time,fn,entry); 81 | } 82 | 83 | if (!entry.cancelFn) { 84 | entry.cancelFn = function cancel(){ 85 | if (entry.timer) { 86 | clearTimeout(entry.timer); 87 | entry.timer = entry.cancelFn = null; 88 | } 89 | }; 90 | } 91 | return entry.cancelFn; 92 | } 93 | 94 | function run(fn,entry) { 95 | entry.timer = entry.cancelFn = null; 96 | entry.last = Date.now(); 97 | fn(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import { 2 | supportsWebAuthn, 3 | supportsWAUserVerification, 4 | listLocalIdentities, 5 | clearLockKeyCache, 6 | removeLocalAccount, 7 | getLockKey, 8 | lockData, 9 | unlockData, 10 | signData, 11 | verifySignature, 12 | configure, 13 | resetAbortReason, 14 | } 15 | // note: this module specifier comes from the import-map 16 | // in index.html; swap "src" for "dist" here to test 17 | // against the dist/* files 18 | from "local-data-lock/src"; 19 | import SSStore from "@byojs/storage/session-storage"; 20 | 21 | // simple helper util for showing a spinner 22 | // (during slower passkey operations) 23 | import { startSpinner, stopSpinner, } from "./spinner.js"; 24 | 25 | 26 | configure({ 27 | accountStorage: "session-storage", 28 | }); 29 | 30 | 31 | // *********************** 32 | 33 | var passkeyKeepAliveEl; 34 | var setPasskeyKeepAliveBtn; 35 | var passkeyTimeoutEl; 36 | var setPasskeyTimeoutBtn; 37 | var registerAccountBtn; 38 | var detectAccountBtn; 39 | var resetAllAccountsBtn; 40 | var selectAccountEl; 41 | var unlockAccountBtn; 42 | var addPasskeyBtn; 43 | var resetAccountBtn; 44 | var lockAccountBtn; 45 | var accountDataEl; 46 | var saveDataBtn; 47 | var signVerifyBtn; 48 | 49 | var currentAccountID; 50 | var localAccountIDs = await listLocalIdentities(); 51 | var passkeyTimeout = 0; 52 | 53 | if (document.readyState == "loading") { 54 | document.addEventListener("DOMContentLoaded",ready,false); 55 | } 56 | else { 57 | ready(); 58 | } 59 | 60 | 61 | // *********************** 62 | 63 | async function ready() { 64 | passkeyKeepAliveEl = document.getElementById("passkey-keep-alive"); 65 | setPasskeyKeepAliveBtn = document.getElementById("set-passkey-keep-alive-btn"); 66 | passkeyTimeoutEl = document.getElementById("passkey-timeout"); 67 | setPasskeyTimeoutBtn = document.getElementById("set-passkey-timeout-btn"); 68 | registerAccountBtn = document.getElementById("register-account-btn"); 69 | detectAccountBtn = document.getElementById("detect-account-btn"); 70 | resetAllAccountsBtn = document.getElementById("reset-all-accounts-btn"); 71 | selectAccountEl = document.getElementById("select-account"); 72 | unlockAccountBtn = document.getElementById("unlock-account-btn"); 73 | addPasskeyBtn = document.getElementById("add-passkey-btn"); 74 | resetAccountBtn = document.getElementById("reset-account-btn"); 75 | lockAccountBtn = document.getElementById("lock-account-btn"); 76 | accountDataEl = document.getElementById("account-data"); 77 | saveDataBtn = document.getElementById("save-data-btn"); 78 | signVerifyBtn = document.getElementById("sign-verify-btn"); 79 | 80 | selectAccountEl.addEventListener("change",changeSelectedAccount,false); 81 | accountDataEl.addEventListener("input",onChangeAccountData,false); 82 | 83 | setPasskeyKeepAliveBtn.addEventListener("click",setKeepAlive,false); 84 | setPasskeyTimeoutBtn.addEventListener("click",setPasskeyTimeout,false); 85 | registerAccountBtn.addEventListener("click",registerAccount,false); 86 | detectAccountBtn.addEventListener("click",detectAccount,false); 87 | resetAllAccountsBtn.addEventListener("click",resetAllAccounts,false); 88 | unlockAccountBtn.addEventListener("click",unlockAccount,false); 89 | addPasskeyBtn.addEventListener("click",addPasskey,false); 90 | resetAccountBtn.addEventListener("click",resetAccount,false); 91 | lockAccountBtn.addEventListener("click",lockAccount,false); 92 | saveDataBtn.addEventListener("click",saveData,false); 93 | signVerifyBtn.addEventListener("click",signAndVerify,false); 94 | 95 | updateElements(); 96 | } 97 | 98 | function updateElements() { 99 | selectAccountEl.disabled = (localAccountIDs.length == 0); 100 | selectAccountEl.options.length = 1; 101 | for (let localID of localAccountIDs) { 102 | let optionEl = document.createElement("option"); 103 | optionEl.value = localID; 104 | optionEl.innerHTML = localID; 105 | selectAccountEl.appendChild(optionEl); 106 | } 107 | 108 | if (localAccountIDs.length > 0) { 109 | detectAccountBtn.disabled = false; 110 | resetAllAccountsBtn.disabled = false; 111 | } 112 | else { 113 | detectAccountBtn.disabled = true; 114 | resetAllAccountsBtn.disabled = true; 115 | unlockAccountBtn.disabled = true; 116 | } 117 | 118 | if (localAccountIDs.includes(currentAccountID)) { 119 | selectAccountEl.value = currentAccountID; 120 | addPasskeyBtn.disabled = false; 121 | resetAccountBtn.disabled = false; 122 | lockAccountBtn.disabled = false; 123 | signVerifyBtn.disabled = false; 124 | accountDataEl.disabled = false; 125 | } 126 | else { 127 | addPasskeyBtn.disabled = true; 128 | resetAccountBtn.disabled = true; 129 | lockAccountBtn.disabled = true; 130 | signVerifyBtn.disabled = true; 131 | accountDataEl.disabled = true; 132 | accountDataEl.value = ""; 133 | selectAccountEl.selectedIndex = 0; 134 | } 135 | } 136 | 137 | function changeSelectedAccount() { 138 | if (selectAccountEl.selectedIndex > 0) { 139 | unlockAccountBtn.disabled = false; 140 | } 141 | else { 142 | unlockAccountBtn.disabled = true; 143 | } 144 | } 145 | 146 | function onChangeAccountData() { 147 | saveDataBtn.disabled = false; 148 | } 149 | 150 | async function setKeepAlive() { 151 | var keepAlive = Math.max(1,Number(passkeyKeepAliveEl.value != null ? passkeyKeepAliveEl.value : 30)); 152 | passkeyKeepAliveEl.value = keepAlive; 153 | 154 | configure({ cacheLifetime: keepAlive * 60 * 1000, }); 155 | showToast(`Passkey Keep-Alive set to ${keepAlive} minute(s)`); 156 | } 157 | 158 | async function setPasskeyTimeout() { 159 | passkeyTimeout = Math.max(0,Number(passkeyTimeoutEl.value != null ? passkeyTimeoutEl.value : 0)); 160 | passkeyTimeoutEl.value = passkeyTimeout; 161 | 162 | if (passkeyTimeout > 0) { 163 | showToast(`Passkey Timeout set to ${passkeyTimeout} second(s)`); 164 | } 165 | else { 166 | showToast(`Passkey Timeout disabled (0 seconds)`); 167 | } 168 | } 169 | 170 | async function promptAddPasskey() { 171 | if (!checkWebAuthnSupport()) return; 172 | 173 | var passkeyUsernameEl; 174 | var passkeyDisplayNameEl; 175 | 176 | var result = await Swal.fire({ 177 | title: "Add Passkey", 178 | html: ` 179 |

180 | 184 |

185 |

186 | 190 |

191 | `, 192 | showConfirmButton: true, 193 | confirmButtonText: "Add", 194 | confirmButtonColor: "darkslateblue", 195 | showCancelButton: true, 196 | cancelButtonColor: "darkslategray", 197 | 198 | allowOutsideClick: true, 199 | allowEscapeKey: true, 200 | 201 | didOpen(popupEl) { 202 | passkeyUsernameEl = document.getElementById("passkey-username"); 203 | passkeyDisplayNameEl = document.getElementById("passkey-display-name"); 204 | passkeyUsernameEl.focus(); 205 | popupEl.addEventListener("keypress",onKeypress,true); 206 | }, 207 | 208 | willClose(popupEl) { 209 | popupEl.removeEventListener("keypress",onKeypress,true); 210 | passkeyUsernameEl = passkeyDisplayNameEl = null; 211 | }, 212 | 213 | preConfirm() { 214 | var passkeyUsername = passkeyUsernameEl.value.trim(); 215 | var passkeyDisplayName = passkeyDisplayNameEl.value.trim(); 216 | 217 | if (!passkeyUsername) { 218 | Swal.showValidationMessage("Please enter a username."); 219 | return false; 220 | } 221 | if (!passkeyDisplayName) { 222 | Swal.showValidationMessage("Please enter a display name."); 223 | return false; 224 | } 225 | 226 | return { passkeyUsername, passkeyDisplayName, }; 227 | }, 228 | }); 229 | 230 | if (result.isConfirmed) { 231 | return result.value; 232 | } 233 | 234 | 235 | // *********************** 236 | 237 | function onKeypress(evt) { 238 | if ( 239 | evt.key == "Enter" && 240 | evt.target.matches(".swal2-input, .swal2-select, .swal2-textarea") 241 | ) { 242 | evt.preventDefault(); 243 | evt.stopPropagation(); 244 | evt.stopImmediatePropagation(); 245 | Swal.clickConfirm(); 246 | } 247 | } 248 | } 249 | 250 | async function registerAccount() { 251 | var { passkeyUsername: username, passkeyDisplayName: displayName, } = (await promptAddPasskey() || {}); 252 | 253 | if (!(username != null && displayName != null)) { 254 | return; 255 | } 256 | 257 | var { signal, intv } = createTimeoutToken(passkeyTimeout) || {}; 258 | try { 259 | startSpinner(); 260 | let key = await getLockKey({ 261 | addNewPasskey: true, 262 | username, 263 | displayName, 264 | signal, 265 | }); 266 | if (intv != null) { clearTimeout(intv); } 267 | localAccountIDs = await listLocalIdentities(); 268 | if (!localAccountIDs.includes(key.localIdentity)) { 269 | throw new Error("No account found for selected passkey"); 270 | } 271 | selectAccountEl.value = currentAccountID = key.localIdentity; 272 | await unlockAccountData(currentAccountID,key); 273 | updateElements(); 274 | changeSelectedAccount(); 275 | stopSpinner(); 276 | showToast("Account (and passkey) registered."); 277 | } 278 | catch (err) { 279 | if (intv != null) { clearTimeout(intv); } 280 | logError(err); 281 | stopSpinner(); 282 | showError("Registering account and passkey failed."); 283 | } 284 | } 285 | 286 | async function detectAccount() { 287 | if (!checkWebAuthnSupport()) return; 288 | 289 | var { signal, intv } = createTimeoutToken(passkeyTimeout) || {}; 290 | try { 291 | startSpinner(); 292 | let key = await getLockKey({ signal, }); 293 | if (intv != null) { clearTimeout(intv); } 294 | if (!localAccountIDs.includes(key.localIdentity)) { 295 | throw new Error("No account matching selected passkey"); 296 | } 297 | selectAccountEl.value = currentAccountID = key.localIdentity; 298 | await unlockAccountData(currentAccountID,key); 299 | updateElements(); 300 | changeSelectedAccount(); 301 | stopSpinner(); 302 | showToast("Account detected and unlocked via passkey."); 303 | } 304 | catch (err) { 305 | if (intv != null) { clearTimeout(intv); } 306 | logError(err); 307 | stopSpinner(); 308 | showError("Detecting account via passkey authentication failed."); 309 | } 310 | } 311 | 312 | async function resetAllAccounts() { 313 | var confirmResult = await Swal.fire({ 314 | text: "Resetting will remove all local account data and passkeys. Are you sure?", 315 | icon: "warning", 316 | showConfirmButton: true, 317 | confirmButtonText: "Yes, reset!", 318 | confirmButtonColor: "darkslateblue", 319 | showCancelButton: true, 320 | cancelButtonColor: "darkslategray", 321 | cancelButtonText: "No", 322 | allowOutsideClick: true, 323 | allowEscapeKey: true, 324 | }); 325 | 326 | if (confirmResult.isConfirmed) { 327 | for (let accountID of localAccountIDs) { 328 | await removeLocalAccount(accountID); 329 | await SSStore.remove(`account-data-${accountID}`); 330 | } 331 | localAccountIDs.length = 0; 332 | updateElements(); 333 | showToast("All local accounts removed."); 334 | } 335 | } 336 | 337 | async function unlockAccount() { 338 | if (!checkWebAuthnSupport()) return; 339 | 340 | if (selectAccountEl.selectedIndex == 0) { 341 | return; 342 | } 343 | 344 | var { signal, intv } = createTimeoutToken(passkeyTimeout) || {}; 345 | try { 346 | startSpinner(); 347 | let key = await getLockKey({ 348 | localIdentity: selectAccountEl.value, 349 | signal, 350 | }); 351 | if (intv != null) { clearTimeout(intv); } 352 | if (!localAccountIDs.includes(key.localIdentity)) { 353 | throw new Error("No account found for selected passkey"); 354 | } 355 | selectAccountEl.value = currentAccountID = key.localIdentity; 356 | await unlockAccountData(currentAccountID,key); 357 | updateElements(); 358 | changeSelectedAccount(); 359 | stopSpinner(); 360 | showToast("Account unlocked."); 361 | } 362 | catch (err) { 363 | if (intv != null) { clearTimeout(intv); } 364 | logError(err); 365 | stopSpinner(); 366 | showError("Unlocking account via passkey failed."); 367 | } 368 | } 369 | 370 | async function addPasskey() { 371 | var { passkeyUsername: username, passkeyDisplayName: displayName, } = (await promptAddPasskey() || {}); 372 | 373 | if (!(username != null && displayName != null)) { 374 | return; 375 | } 376 | 377 | var { signal, intv } = createTimeoutToken(passkeyTimeout) || {}; 378 | try { 379 | startSpinner(); 380 | let result = await getLockKey({ 381 | localIdentity: currentAccountID, 382 | addNewPasskey: true, 383 | username, 384 | displayName, 385 | signal, 386 | }); 387 | if (intv != null) { clearTimeout(intv); } 388 | stopSpinner(); 389 | if (result != null) { 390 | showToast("Additional passkey added."); 391 | } 392 | } 393 | catch (err) { 394 | if (intv != null) { clearTimeout(intv); } 395 | logError(err); 396 | stopSpinner(); 397 | showError("Adding new passkey failed."); 398 | } 399 | } 400 | 401 | async function resetAccount() { 402 | if (!checkWebAuthnSupport()) return; 403 | 404 | var confirmResult = await Swal.fire({ 405 | text: "Resetting an account regenerates a new encryption/decryption key and a new passkey, while discarding previously associated passkeys. Are you sure?", 406 | icon: "warning", 407 | showConfirmButton: true, 408 | confirmButtonText: "Yes, reset!", 409 | confirmButtonColor: "darkslateblue", 410 | showCancelButton: true, 411 | cancelButtonColor: "darkslategray", 412 | cancelButtonText: "No", 413 | allowOutsideClick: true, 414 | allowEscapeKey: true, 415 | }); 416 | 417 | if (confirmResult.isConfirmed) { 418 | let { passkeyUsername: username, passkeyDisplayName: displayName, } = ( 419 | (await promptAddPasskey()) || {} 420 | ); 421 | 422 | if (!(username != null && displayName != null)) { 423 | return; 424 | } 425 | 426 | let { signal, intv } = createTimeoutToken(passkeyTimeout) || {}; 427 | try { 428 | startSpinner(); 429 | let key = await getLockKey({ 430 | localIdentity: currentAccountID, 431 | resetLockKey: true, 432 | username, 433 | displayName, 434 | signal, 435 | }); 436 | if (intv != null) { clearTimeout(intv); } 437 | if (!localAccountIDs.includes(key.localIdentity)) { 438 | throw new Error("No account found for selected passkey"); 439 | } 440 | if (accountDataEl.value != "") { 441 | await lockAccountData(currentAccountID,key,accountDataEl.value); 442 | } 443 | else { 444 | await storeAccountData(currentAccountID,""); 445 | } 446 | stopSpinner(); 447 | showToast("Account lock-key reset (and previous passkeys discarded)."); 448 | } 449 | catch (err) { 450 | if (intv != null) { clearTimeout(intv); } 451 | logError(err); 452 | stopSpinner(); 453 | showError("Resetting account failed."); 454 | } 455 | } 456 | } 457 | 458 | async function lockAccount() { 459 | clearLockKeyCache(currentAccountID); 460 | currentAccountID = null; 461 | selectAccountEl.selectedIndex = 0; 462 | changeSelectedAccount(); 463 | updateElements(); 464 | showToast("Account locked."); 465 | } 466 | 467 | async function saveData() { 468 | if (!checkWebAuthnSupport()) return; 469 | 470 | var { signal, intv } = createTimeoutToken(passkeyTimeout) || {}; 471 | try { 472 | startSpinner(); 473 | let key = await getLockKey({ 474 | localIdentity: currentAccountID, 475 | signal, 476 | }); 477 | if (intv != null) { clearTimeout(intv); } 478 | if (accountDataEl.value != "") { 479 | await lockAccountData(currentAccountID,key,accountDataEl.value); 480 | } 481 | else { 482 | await storeAccountData(currentAccountID,""); 483 | } 484 | saveDataBtn.disabled = true; 485 | stopSpinner(); 486 | showToast("Data encrypted and saved."); 487 | } 488 | catch (err) { 489 | if (intv != null) { clearTimeout(intv); } 490 | logError(err); 491 | stopSpinner(); 492 | showError("Saving (encrypted!) data to account failed."); 493 | } 494 | } 495 | 496 | async function signAndVerify() { 497 | if (!checkWebAuthnSupport()) return; 498 | 499 | var result = await Swal.fire({ 500 | title: "Enter some text to sign", 501 | input: "text", 502 | inputLabel: "Text", 503 | inputValue: accountDataEl.value, 504 | inputAttributes: { 505 | autocapitalize: "off", 506 | }, 507 | showConfirmButton: true, 508 | confirmButtonText: "Sign", 509 | confirmButtonColor: "darkslateblue", 510 | showCancelButton: true, 511 | cancelButtonColor: "darkslategray", 512 | allowOutsideClick: true, 513 | allowEscapeKey: true, 514 | 515 | preConfirm(textToSign) { 516 | if (!textToSign) { 517 | Swal.showValidationMessage("Please enter text to sign."); 518 | return false; 519 | } 520 | return textToSign; 521 | }, 522 | }); 523 | 524 | if (result.isConfirmed) { 525 | let { signal, intv } = createTimeoutToken(passkeyTimeout) || {}; 526 | try { 527 | startSpinner(); 528 | let key = await getLockKey({ 529 | localIdentity: currentAccountID, 530 | signal, 531 | }); 532 | if (intv != null) { clearTimeout(intv); } 533 | 534 | let msg = { 535 | text: result.value, 536 | timestamp: Date.now(), 537 | }; 538 | let signature = signData(msg,key); 539 | let verified = verifySignature(msg,key,signature); 540 | 541 | stopSpinner(); 542 | await Swal.fire({ 543 | title: "Signature & Verification", 544 | html: ` 545 |
546 |

547 |

${
548 | 								JSON.stringify(msg,null,"   ")
549 | 							}
550 |

551 |

552 | Signature: ${signature} 553 |

554 |

${verified ? "Verified!" : "Not verified"}

555 |
556 | `, 557 | showConfirmButton: true, 558 | confirmButtonText: "OK", 559 | confirmButtonColor: "darkslateblue", 560 | showCancelButton: false, 561 | allowOutsideClick: true, 562 | allowEscapeKey: true, 563 | }); 564 | } 565 | catch (err) { 566 | if (intv != null) { clearTimeout(intv); } 567 | logError(err); 568 | stopSpinner(); 569 | showError("Signing/verifying failed."); 570 | } 571 | } 572 | } 573 | 574 | async function unlockAccountData(accountID,key) { 575 | var data = await loadAccountData(accountID); 576 | if (typeof data == "string") { 577 | if (data != "") { 578 | let text = unlockData(data,key,{ parseJSON: false, }); 579 | accountDataEl.value = text; 580 | } 581 | else { 582 | accountDataEl.value = ""; 583 | } 584 | } 585 | else { 586 | accountDataEl.value = ""; 587 | } 588 | } 589 | 590 | async function lockAccountData(accountID,key,data) { 591 | await storeAccountData(accountID,lockData(data,key)); 592 | } 593 | 594 | async function loadAccountData(accountID) { 595 | var data = await SSStore.get(`account-data-${accountID}`); 596 | if (typeof data == "string") { 597 | return data; 598 | } 599 | } 600 | 601 | async function storeAccountData(accountID,data) { 602 | await SSStore.set(`account-data-${accountID}`,data); 603 | } 604 | 605 | function logError(err,returnLog = false) { 606 | var err = `${ 607 | err.stack ? err.stack : err.toString() 608 | }${ 609 | err.cause ? `\n${logError(err.cause,/*returnLog=*/true)}` : "" 610 | }`; 611 | if (returnLog) return err; 612 | else console.error(err); 613 | } 614 | 615 | function showError(errMsg) { 616 | return Swal.fire({ 617 | title: "Error!", 618 | text: errMsg, 619 | icon: "error", 620 | confirmButtonText: "OK", 621 | }); 622 | } 623 | 624 | function showToast(toastMsg) { 625 | return Swal.fire({ 626 | text: toastMsg, 627 | showConfirmButton: false, 628 | showCloseButton: true, 629 | timer: 5000, 630 | toast: true, 631 | position: "top-end", 632 | customClass: { 633 | popup: "toast-popup", 634 | }, 635 | }); 636 | } 637 | 638 | function createTimeoutToken(seconds) { 639 | if (seconds > 0) { 640 | let ac = new AbortController(); 641 | let intv = setTimeout(() => ac.abort("Timeout!"),seconds * 1000); 642 | return { signal: ac.signal, intv, }; 643 | } 644 | } 645 | 646 | async function checkWebAuthnSupport() { 647 | if (!( 648 | supportsWebAuthn && 649 | supportsWAUserVerification 650 | )) { 651 | showError("Sorry, but this device doesn't seem to support the proper passkey functionality (including user-verification)."); 652 | return false; 653 | } 654 | } 655 | --------------------------------------------------------------------------------