2 Commits
Author SHA1 Message Date
zemion 12696657a0 Release Auth Tools v0.3.1 2026-09-01 15:39:32 +02:00
zemion 6afa59d18f feat: release authentication laboratories 0.3.0 2026-08-20 00:21:31 +02:00
48 changed files with 4355 additions and 71 deletions
+19
View File
@@ -4,6 +4,25 @@ All notable changes are documented here.
## Unreleased
## 0.3.1 - 2026-09-01
- Corrected the packaged corresponding-source identity to name this release and its immutable source tag.
- Serialized the unit-test worker pool so release verification remains reliable on shared filesystems.
- Normalized ZIP creation to UTC so release bytes remain reproducible across host timezones.
## 0.3.0 - 2026-08-19
- Added a live WebAuthn Extensions Lab for `credProps`, PRF, `largeBlob`, `appid` and `appidExclude`, including client-capability detection and normalized request/result inspection.
- Added bounded, versioned WebAuthn ceremony traces with raw and redacted export, offline cryptographic replay and structural comparison.
- Added user-pinned Metadata Service trust roots, signer-chain signature validation and historical, firmware-aware attestation status policy evaluation.
- Added strict Google Authenticator migration export with multi-part QR rendering and loss-prevention checks.
- Added local password-vault decryption for Aegis AES-256-GCM/scrypt password slots with bounded work parameters.
- Added an authenticated RFC 6030 PSKC password profile using PBKDF2, AES-CBC, an encrypted MAC key and per-secret HMAC `ValueMAC` checks.
- Added secret-free collection snapshots, before/after comparison, secret-reuse detection and prioritized rotation planning.
- Added bounded HOTP counter resynchronization with optional consecutive-code confirmation.
- Added explicitly triggered camera QR scanning with native/fallback decoding, automatic track cleanup and a dedicated-origin Permissions Policy.
- Added `@noble/hashes` 2.3.0 for audited, asynchronous local scrypt derivation; the locked dependency audit reports no known vulnerabilities.
## 0.2.0 - 2026-08-19
- Added auditable TOTP time travel using live time, bounded positive or negative period offsets, or a frozen local date/time interpreted in an explicit IANA timezone.
+15 -9
View File
@@ -11,15 +11,19 @@ Authentication material stays in the active tab. The application has no backend,
- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation.
- Bounded clock-drift search, past/current/future OTP timelines, downloadable interoperability vectors and custom RFC 6238 T0 diagnostics.
- RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths.
- Import of line-delimited provisioning URIs, complete Google Authenticator multi-QR batches, Aegis plaintext JSON, 2FAS JSON, andOTP plaintext JSON, legacy FreeOTP JSON, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Unsupported encrypted vendor formats are rejected rather than guessed.
- Cross-browser QR image decoding through native `BarcodeDetector` when available and a bundled jsQR fallback everywhere else.
- Credential-collection health reports for duplicated secrets/labels, weak secret lengths and non-default timing.
- Import of line-delimited provisioning URIs, complete Google Authenticator multi-QR batches, password-encrypted or plaintext Aegis JSON, 2FAS JSON, andOTP plaintext JSON, legacy FreeOTP JSON, the documented CSV shape, and plain or password-encrypted RFC 6030 PSKC.
- Google Authenticator migration export in bounded multi-part QR batches and an authenticated PSKC password profile using PBKDF2, AES-CBC, an encrypted MAC key and per-secret `ValueMAC` verification.
- Cross-browser QR image decoding through native `BarcodeDetector` when available and a bundled jsQR fallback everywhere else, including an explicitly triggered, time-limited camera scanner on the dedicated origin.
- Credential-collection health reports, secret-free inventory snapshots, before/after comparison and prioritized rotation plans for duplicated secrets, changed parameters and removed credentials.
- Bounded HOTP resynchronization with one code or stronger consecutive-code confirmation; suggested counters are never applied automatically.
- Explicit URI-list and CSV export with an unencrypted-secret warning, plus a versioned authenticated encrypted backup using PBKDF2-SHA-256 and AES-256-GCM.
- Bounded CBOR, `clientDataJSON`, authenticator-data and attestation-object inspection, including flags, RP ID hash, counter, AAGUID, credential ID, COSE key and extensions.
- Layered assertion verification for challenge, origin, cross-origin state, RP ID hash, user presence/verification, signature counter, and ES256, RS256, PS256 or Ed25519 signatures.
- Layered registration verification for none, packed/self, FIDO U2F, TPM 2.0, Android Key and Apple anonymous attestation, including certificate signatures and format-specific credential/challenge bindings.
- Explicit offline FIDO Metadata Service v3 JWT snapshot import, signer verification, AAGUID matching and authenticator status display. Imported metadata never silently establishes a trust root.
- Explicit offline FIDO Metadata Service v3 JWT snapshot import, signer verification, user-pinned PEM trust roots, signer-chain validation, historical effective-date and firmware-aware status policy evaluation. Imported metadata never silently establishes trust.
- Serializable registration/request option examples, browser capability diagnostics and an ephemeral in-memory live ceremony lab.
- A WebAuthn Extensions Lab for `credProps`, `prf`, `largeBlob`, `appid` and `appidExclude`, with `getClientCapabilities()` inspection and normalized request/result output.
- Versioned registration/authentication trace capture, raw or privacy-redacted export, offline cryptographic replay and structural comparison.
## Security boundary
@@ -31,7 +35,7 @@ https://auth.toolbox.add-ideas.de/
The same immutable release can be served there, top-level, with RP ID `auth.toolbox.add-ideas.de`. The RP ID is never widened to `add-ideas.de`, and arbitrary RP IDs can only be inspected offline.
Raw CTAP/token administration is not claimed: browsers expose ordinary WebAuthn but block the FIDO HID usage page from WebHID. Camera and USB permissions are not requested. QR images can be selected locally in current browsers; provisioning text can always be pasted.
Raw CTAP/token administration is not claimed: browsers expose ordinary WebAuthn but block the FIDO HID usage page from WebHID. USB is never requested. Camera access is granted only on the dedicated Auth Tools host and is requested only after clicking **Scan with camera**; tracks stop on success, cancellation, timeout or teardown. QR images and pasted provisioning text remain available without camera permission.
See [SECURITY.md](SECURITY.md) for the threat model and limitations.
@@ -50,11 +54,11 @@ The Vite base is relative, so both `/` and nested paths work. `npm run toolbox:c
## Static deployment
Serve `dist/` or the contents of `release/auth-tools-0.2.0.zip` over HTTPS. Recommended response headers:
Serve `dist/` or the contents of `release/auth-tools-0.3.1.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin:
```text
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'
Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=()
Permissions-Policy: camera=(self), microphone=(), geolocation=(), usb=()
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Cross-Origin-Opener-Policy: same-origin
@@ -67,10 +71,12 @@ For Portal integration, pin the immutable Gitea release ZIP and SHA-256 in `tool
- This is an engineering and interoperability workbench, not a certified authenticator, relying-party server, password manager, FIDO certification tool or recovery system.
- Live credentials are forgotten on reload and cannot be exported by WebAuthn design.
- Attestation cryptography and metadata status are evidence, not automatic device trust. No trust root or policy is bundled, and FIDO metadata is never downloaded silently.
- All parts of a Google multi-QR batch must be supplied. Google migration export, Aegis password-vault decryption, FreeOTP 2.x encrypted XML and encrypted PSKC decryption are not claimed.
- Attestation cryptography and metadata status are evidence, not automatic device trust. No trust root is bundled, metadata is never downloaded silently, and the local signer-chain verifier is a pinned-root signature path check rather than a complete RFC 5280 PKIX/revocation implementation.
- All parts of a Google multi-QR batch must be supplied. Google export refuses settings its format cannot preserve. Aegis biometric/raw-key slots and FreeOTP 2.x encrypted XML are not claimed.
- Encrypted PSKC supports the documented password-derived PBKDF2 + AES-CBC + HMAC `ValueMAC` profile. A namespaced Auth Tools extension preserves non-SHA-1 OTP hash selection without changing the RFC container structure. Asymmetric, pre-shared-key and unverified encrypted-value profiles are rejected.
- CSV and URI exports contain raw OTP seeds. The encrypted export is a portable backup file, not a persistent browser vault or recovery service.
- QR generation supports byte-mode error-correction M through version 10. Larger provisioning records remain available as text exports.
- Redacted WebAuthn traces remove labels and top-level identifiers but retain signed ceremony blobs needed for replay; those blobs can contain stable correlatable identifiers.
## Licence
+12 -3
View File
@@ -6,12 +6,15 @@ All inputs are processed in the browser tab. There are no application `fetch`, W
Downloads and clipboard writes require a user action. URI/CSV exports are not encrypted; the dedicated encrypted format uses an authenticated cipher and a user-supplied password. The UI masks OTP secrets and provisioning URIs by default, but masking is not memory protection.
Camera capture is never started automatically. The dedicated origin requests it only after the user selects camera scanning, reads bounded frames locally, and stops all media tracks after a match, cancellation, timeout or component teardown. The shared Portal origin keeps camera permission disabled.
## Trust boundaries
- The static release and its hosting origin are trusted code. A compromised host can alter JavaScript and steal subsequently entered material; verify the release SHA-256 when this matters.
- Imported URIs, CSV, XML, protobuf, JSON, Base64url and CBOR are untrusted. Size/depth/count limits are applied before or during decoding. Imported text is rendered by React, never as markup. The only generated markup insertion is the project-owned QR SVG serializer, which receives an already serialized provisioning URI and emits path coordinates only.
- WebAuthn client data is verified as exact strings/bytes. Origins and RP IDs are not suffix-matched. The expected challenge is caller-supplied ceremony state; the tool cannot prove that a server generated or consumed it once.
- Assertion signatures establish consistency with the supplied COSE public key. Attestation checks establish format-specific cryptographic bindings; certificate-chain trust and authenticator policy remain separate. Imported FIDO metadata has its JWS signer checked but does not silently install a trust root.
- Assertion signatures establish consistency with the supplied COSE public key. Attestation checks establish format-specific cryptographic bindings; certificate-chain trust and authenticator policy remain separate. Imported FIDO metadata has its JWS signer checked and can be evaluated against explicitly supplied roots, but no root is installed or downloaded silently. The signer-chain check validates supported certificate signatures to the pinned root; it is not a full RFC 5280 name-constraints, policy, revocation or path-building engine.
- WebAuthn trace redaction removes user labels, handles and top-level credential identifiers. Signed client/authenticator/attestation blobs remain intact for replay and can still contain stable identifiers; redacted traces require review before publication.
## Origin isolation
@@ -19,22 +22,28 @@ Downloads and clipboard writes require a user action. URI/CSV exports are not en
## Cryptography
HMAC, digest, random generation, PBKDF2 key derivation, AES-GCM authenticated encryption, public-key import and signature verification use the browser Web Cryptography API. No custom cryptographic primitive is implemented. OTP dynamic truncation, OCRA data-input assembly and bounded DER/TPM structure interpretation are project code covered by tests.
HMAC, digest, random generation, PBKDF2 key derivation, AES-GCM authenticated encryption, PSKC AES-CBC encryption/decryption, public-key import and signature verification use the browser Web Cryptography API. Aegis password slots use the maintained `@noble/hashes` scrypt implementation with explicit memory/work bounds. No project-authored cryptographic primitive is used for key derivation or encryption. OTP dynamic truncation, OCRA data-input assembly and bounded DER/TPM structure interpretation are project code covered by tests.
The encrypted backup format uses a 16-byte random salt, 600,000 PBKDF2-HMAC-SHA-256 iterations, a 256-bit AES-GCM key, a 96-bit random IV, a 128-bit authentication tag and authenticated version/KDF/cipher metadata. Decryption failures do not expose partial plaintext. It is an export format, not persistent storage.
The password-encrypted PSKC profile derives a 128/192/256-bit AES-CBC key with bounded PBKDF2, encrypts a random MAC key with that key, prepends a fresh IV to every encrypted value, and authenticates every encrypted secret with HMAC before decryption. A PSKC extension in the Auth Tools namespace preserves the selected OTP hash. Unsupported or unauthenticated encrypted PSKC profiles are rejected.
SHA-1 remains available only where HOTP/TOTP/OCRA interoperability standards require it; it is used inside HMAC or as an explicitly selected OCRA PIN digest, not as a collision-resistant document signature.
## Parser limits
- Provisioning URI: 16 KiB.
- Migration/CSV/PSKC input: 4 MiB; vendor/encrypted backup input: 16 MiB.
- Migration/CSV/plain PSKC input: 4 MiB; encrypted PSKC and vendor/encrypted backup input: 16 MiB.
- Google protobuf: bounded input, varints at most 10 bytes, supported wire types only.
- CBOR: 4 MiB, depth 32, 10,000 items, definite lengths only, duplicate map keys rejected.
- OTP secret: 1 KiB; random generation: 161,024 bytes.
- Verification windows: routine TOTP 100 steps maximum; explicit drift search 10,000 periods; HOTP 10,000 counters maximum.
- Aegis password derivation: scrypt N at most 65,536, r at most 16, p at most 4 and a 64 MiB memory policy.
- PSKC password derivation: PBKDF2 at most 2,000,000 iterations; export requires at least 100,000 iterations and a 12-character password.
- WebAuthn trace and OTP collection snapshot: 8 MiB; collection/profile count 10,000.
- QR encoder: version 10 maximum. QR decoder: 16 MiB, 4096 px per side and 16 megapixels.
- FIDO Metadata BLOB: 32 MiB, 100,000 entries and 20 signer certificates maximum.
- Camera QR scan: 60 seconds, about 10 frames/second and 1,280 px maximum processing edge.
## Not persistent storage or an identity provider
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance
The corresponding source for OTP & Passkey Tools 0.2.0 is published at:
The corresponding source for OTP & Passkey Tools 0.3.1 is published at:
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.2.0
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.3.1
Build that tag with Node.js 22 and the exact `package-lock.json`:
+2 -1
View File
@@ -8,11 +8,12 @@ The release preparation step collates licence files for the exact locked runtime
| -------------------------------- | ------- | ---------- | ------------------------------- | ------------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
| `@noble/hashes` | 2.3.0 | MIT | local Aegis scrypt derivation | https://github.com/paulmillr/noble-hashes |
| `jsqr` | 1.4.0 | Apache-2.0 | local QR image decoding | https://github.com/cozmo/jsQR |
| `react` | 19.2.6 | MIT | application UI | https://github.com/facebook/react |
| `react-dom` | 19.2.6 | MIT | browser rendering | https://github.com/facebook/react |
`scheduler`, distributed by the React project under MIT, is the only transitive runtime package. Exact licence texts, including jsQR's Apache-2.0 terms, are collated from the locked install into the release.
`scheduler`, distributed by the React project under MIT, is the only transitive runtime package. Exact licence texts, including jsQR's Apache-2.0 and noble-hashes' MIT terms, are collated from the locked install into the release.
## QR construction
+15 -2
View File
@@ -1,16 +1,17 @@
{
"name": "auth-tools",
"version": "0.2.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "auth-tools",
"version": "0.2.0",
"version": "0.3.1",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@noble/hashes": "2.3.0",
"jsqr": "1.4.0",
"react": "19.2.6",
"react-dom": "19.2.6"
@@ -853,6 +854,18 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3"
}
},
"node_modules/@noble/hashes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz",
"integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oxc-project/types": {
"version": "0.142.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auth-tools",
"version": "0.2.0",
"version": "0.3.1",
"description": "Inspect and test OTP and WebAuthn authentication locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -41,6 +41,7 @@
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@noble/hashes": "2.3.0",
"jsqr": "1.4.0",
"react": "19.2.6",
"react-dom": "19.2.6"
+19
View File
@@ -4,6 +4,25 @@ All notable changes are documented here.
## Unreleased
## 0.3.1 - 2026-09-01
- Corrected the packaged corresponding-source identity to name this release and its immutable source tag.
- Serialized the unit-test worker pool so release verification remains reliable on shared filesystems.
- Normalized ZIP creation to UTC so release bytes remain reproducible across host timezones.
## 0.3.0 - 2026-08-19
- Added a live WebAuthn Extensions Lab for `credProps`, PRF, `largeBlob`, `appid` and `appidExclude`, including client-capability detection and normalized request/result inspection.
- Added bounded, versioned WebAuthn ceremony traces with raw and redacted export, offline cryptographic replay and structural comparison.
- Added user-pinned Metadata Service trust roots, signer-chain signature validation and historical, firmware-aware attestation status policy evaluation.
- Added strict Google Authenticator migration export with multi-part QR rendering and loss-prevention checks.
- Added local password-vault decryption for Aegis AES-256-GCM/scrypt password slots with bounded work parameters.
- Added an authenticated RFC 6030 PSKC password profile using PBKDF2, AES-CBC, an encrypted MAC key and per-secret HMAC `ValueMAC` checks.
- Added secret-free collection snapshots, before/after comparison, secret-reuse detection and prioritized rotation planning.
- Added bounded HOTP counter resynchronization with optional consecutive-code confirmation.
- Added explicitly triggered camera QR scanning with native/fallback decoding, automatic track cleanup and a dedicated-origin Permissions Policy.
- Added `@noble/hashes` 2.3.0 for audited, asynchronous local scrypt derivation; the locked dependency audit reports no known vulnerabilities.
## 0.2.0 - 2026-08-19
- Added auditable TOTP time travel using live time, bounded positive or negative period offsets, or a frozen local date/time interpreted in an explicit IANA timezone.
+28
View File
@@ -398,6 +398,34 @@ Installed from: node_modules/@add-ideas/toolbox-shell-react
limitations under the License.
==============================================================================
@noble/hashes@2.3.0
Declared licence: MIT
Installed from: node_modules/@noble/hashes
==============================================================================
--- LICENSE ---
The MIT License (MIT)
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
==============================================================================
jsqr@1.4.0
Declared licence: Apache-2.0
+15 -9
View File
@@ -11,15 +11,19 @@ Authentication material stays in the active tab. The application has no backend,
- Auditable TOTP time travel using positive/negative period offsets or a frozen local date/time in an explicit IANA timezone, including DST-gap rejection and repeated-time disambiguation.
- Bounded clock-drift search, past/current/future OTP timelines, downloadable interoperability vectors and custom RFC 6238 T0 diagnostics.
- RFC 6287 OCRA-1 suite parsing and computation for counter, numeric/alphanumeric/hex challenge, PIN/password hash, session and timestamp inputs. Official RFC interoperability vectors cover SHA-1, SHA-256 and SHA-512 paths.
- Import of line-delimited provisioning URIs, complete Google Authenticator multi-QR batches, Aegis plaintext JSON, 2FAS JSON, andOTP plaintext JSON, legacy FreeOTP JSON, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Unsupported encrypted vendor formats are rejected rather than guessed.
- Cross-browser QR image decoding through native `BarcodeDetector` when available and a bundled jsQR fallback everywhere else.
- Credential-collection health reports for duplicated secrets/labels, weak secret lengths and non-default timing.
- Import of line-delimited provisioning URIs, complete Google Authenticator multi-QR batches, password-encrypted or plaintext Aegis JSON, 2FAS JSON, andOTP plaintext JSON, legacy FreeOTP JSON, the documented CSV shape, and plain or password-encrypted RFC 6030 PSKC.
- Google Authenticator migration export in bounded multi-part QR batches and an authenticated PSKC password profile using PBKDF2, AES-CBC, an encrypted MAC key and per-secret `ValueMAC` verification.
- Cross-browser QR image decoding through native `BarcodeDetector` when available and a bundled jsQR fallback everywhere else, including an explicitly triggered, time-limited camera scanner on the dedicated origin.
- Credential-collection health reports, secret-free inventory snapshots, before/after comparison and prioritized rotation plans for duplicated secrets, changed parameters and removed credentials.
- Bounded HOTP resynchronization with one code or stronger consecutive-code confirmation; suggested counters are never applied automatically.
- Explicit URI-list and CSV export with an unencrypted-secret warning, plus a versioned authenticated encrypted backup using PBKDF2-SHA-256 and AES-256-GCM.
- Bounded CBOR, `clientDataJSON`, authenticator-data and attestation-object inspection, including flags, RP ID hash, counter, AAGUID, credential ID, COSE key and extensions.
- Layered assertion verification for challenge, origin, cross-origin state, RP ID hash, user presence/verification, signature counter, and ES256, RS256, PS256 or Ed25519 signatures.
- Layered registration verification for none, packed/self, FIDO U2F, TPM 2.0, Android Key and Apple anonymous attestation, including certificate signatures and format-specific credential/challenge bindings.
- Explicit offline FIDO Metadata Service v3 JWT snapshot import, signer verification, AAGUID matching and authenticator status display. Imported metadata never silently establishes a trust root.
- Explicit offline FIDO Metadata Service v3 JWT snapshot import, signer verification, user-pinned PEM trust roots, signer-chain validation, historical effective-date and firmware-aware status policy evaluation. Imported metadata never silently establishes trust.
- Serializable registration/request option examples, browser capability diagnostics and an ephemeral in-memory live ceremony lab.
- A WebAuthn Extensions Lab for `credProps`, `prf`, `largeBlob`, `appid` and `appidExclude`, with `getClientCapabilities()` inspection and normalized request/result output.
- Versioned registration/authentication trace capture, raw or privacy-redacted export, offline cryptographic replay and structural comparison.
## Security boundary
@@ -31,7 +35,7 @@ https://auth.toolbox.add-ideas.de/
The same immutable release can be served there, top-level, with RP ID `auth.toolbox.add-ideas.de`. The RP ID is never widened to `add-ideas.de`, and arbitrary RP IDs can only be inspected offline.
Raw CTAP/token administration is not claimed: browsers expose ordinary WebAuthn but block the FIDO HID usage page from WebHID. Camera and USB permissions are not requested. QR images can be selected locally in current browsers; provisioning text can always be pasted.
Raw CTAP/token administration is not claimed: browsers expose ordinary WebAuthn but block the FIDO HID usage page from WebHID. USB is never requested. Camera access is granted only on the dedicated Auth Tools host and is requested only after clicking **Scan with camera**; tracks stop on success, cancellation, timeout or teardown. QR images and pasted provisioning text remain available without camera permission.
See [SECURITY.md](SECURITY.md) for the threat model and limitations.
@@ -50,11 +54,11 @@ The Vite base is relative, so both `/` and nested paths work. `npm run toolbox:c
## Static deployment
Serve `dist/` or the contents of `release/auth-tools-0.2.0.zip` over HTTPS. Recommended response headers:
Serve `dist/` or the contents of `release/auth-tools-0.3.1.zip` over HTTPS. Recommended response headers for the dedicated Auth Tools origin:
```text
Content-Security-Policy: default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'
Permissions-Policy: camera=(), microphone=(), geolocation=(), usb=()
Permissions-Policy: camera=(self), microphone=(), geolocation=(), usb=()
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
Cross-Origin-Opener-Policy: same-origin
@@ -67,10 +71,12 @@ For Portal integration, pin the immutable Gitea release ZIP and SHA-256 in `tool
- This is an engineering and interoperability workbench, not a certified authenticator, relying-party server, password manager, FIDO certification tool or recovery system.
- Live credentials are forgotten on reload and cannot be exported by WebAuthn design.
- Attestation cryptography and metadata status are evidence, not automatic device trust. No trust root or policy is bundled, and FIDO metadata is never downloaded silently.
- All parts of a Google multi-QR batch must be supplied. Google migration export, Aegis password-vault decryption, FreeOTP 2.x encrypted XML and encrypted PSKC decryption are not claimed.
- Attestation cryptography and metadata status are evidence, not automatic device trust. No trust root is bundled, metadata is never downloaded silently, and the local signer-chain verifier is a pinned-root signature path check rather than a complete RFC 5280 PKIX/revocation implementation.
- All parts of a Google multi-QR batch must be supplied. Google export refuses settings its format cannot preserve. Aegis biometric/raw-key slots and FreeOTP 2.x encrypted XML are not claimed.
- Encrypted PSKC supports the documented password-derived PBKDF2 + AES-CBC + HMAC `ValueMAC` profile. A namespaced Auth Tools extension preserves non-SHA-1 OTP hash selection without changing the RFC container structure. Asymmetric, pre-shared-key and unverified encrypted-value profiles are rejected.
- CSV and URI exports contain raw OTP seeds. The encrypted export is a portable backup file, not a persistent browser vault or recovery service.
- QR generation supports byte-mode error-correction M through version 10. Larger provisioning records remain available as text exports.
- Redacted WebAuthn traces remove labels and top-level identifiers but retain signed ceremony blobs needed for replay; those blobs can contain stable correlatable identifiers.
## Licence
+12 -3
View File
@@ -6,12 +6,15 @@ All inputs are processed in the browser tab. There are no application `fetch`, W
Downloads and clipboard writes require a user action. URI/CSV exports are not encrypted; the dedicated encrypted format uses an authenticated cipher and a user-supplied password. The UI masks OTP secrets and provisioning URIs by default, but masking is not memory protection.
Camera capture is never started automatically. The dedicated origin requests it only after the user selects camera scanning, reads bounded frames locally, and stops all media tracks after a match, cancellation, timeout or component teardown. The shared Portal origin keeps camera permission disabled.
## Trust boundaries
- The static release and its hosting origin are trusted code. A compromised host can alter JavaScript and steal subsequently entered material; verify the release SHA-256 when this matters.
- Imported URIs, CSV, XML, protobuf, JSON, Base64url and CBOR are untrusted. Size/depth/count limits are applied before or during decoding. Imported text is rendered by React, never as markup. The only generated markup insertion is the project-owned QR SVG serializer, which receives an already serialized provisioning URI and emits path coordinates only.
- WebAuthn client data is verified as exact strings/bytes. Origins and RP IDs are not suffix-matched. The expected challenge is caller-supplied ceremony state; the tool cannot prove that a server generated or consumed it once.
- Assertion signatures establish consistency with the supplied COSE public key. Attestation checks establish format-specific cryptographic bindings; certificate-chain trust and authenticator policy remain separate. Imported FIDO metadata has its JWS signer checked but does not silently install a trust root.
- Assertion signatures establish consistency with the supplied COSE public key. Attestation checks establish format-specific cryptographic bindings; certificate-chain trust and authenticator policy remain separate. Imported FIDO metadata has its JWS signer checked and can be evaluated against explicitly supplied roots, but no root is installed or downloaded silently. The signer-chain check validates supported certificate signatures to the pinned root; it is not a full RFC 5280 name-constraints, policy, revocation or path-building engine.
- WebAuthn trace redaction removes user labels, handles and top-level credential identifiers. Signed client/authenticator/attestation blobs remain intact for replay and can still contain stable identifiers; redacted traces require review before publication.
## Origin isolation
@@ -19,22 +22,28 @@ Downloads and clipboard writes require a user action. URI/CSV exports are not en
## Cryptography
HMAC, digest, random generation, PBKDF2 key derivation, AES-GCM authenticated encryption, public-key import and signature verification use the browser Web Cryptography API. No custom cryptographic primitive is implemented. OTP dynamic truncation, OCRA data-input assembly and bounded DER/TPM structure interpretation are project code covered by tests.
HMAC, digest, random generation, PBKDF2 key derivation, AES-GCM authenticated encryption, PSKC AES-CBC encryption/decryption, public-key import and signature verification use the browser Web Cryptography API. Aegis password slots use the maintained `@noble/hashes` scrypt implementation with explicit memory/work bounds. No project-authored cryptographic primitive is used for key derivation or encryption. OTP dynamic truncation, OCRA data-input assembly and bounded DER/TPM structure interpretation are project code covered by tests.
The encrypted backup format uses a 16-byte random salt, 600,000 PBKDF2-HMAC-SHA-256 iterations, a 256-bit AES-GCM key, a 96-bit random IV, a 128-bit authentication tag and authenticated version/KDF/cipher metadata. Decryption failures do not expose partial plaintext. It is an export format, not persistent storage.
The password-encrypted PSKC profile derives a 128/192/256-bit AES-CBC key with bounded PBKDF2, encrypts a random MAC key with that key, prepends a fresh IV to every encrypted value, and authenticates every encrypted secret with HMAC before decryption. A PSKC extension in the Auth Tools namespace preserves the selected OTP hash. Unsupported or unauthenticated encrypted PSKC profiles are rejected.
SHA-1 remains available only where HOTP/TOTP/OCRA interoperability standards require it; it is used inside HMAC or as an explicitly selected OCRA PIN digest, not as a collision-resistant document signature.
## Parser limits
- Provisioning URI: 16 KiB.
- Migration/CSV/PSKC input: 4 MiB; vendor/encrypted backup input: 16 MiB.
- Migration/CSV/plain PSKC input: 4 MiB; encrypted PSKC and vendor/encrypted backup input: 16 MiB.
- Google protobuf: bounded input, varints at most 10 bytes, supported wire types only.
- CBOR: 4 MiB, depth 32, 10,000 items, definite lengths only, duplicate map keys rejected.
- OTP secret: 1 KiB; random generation: 161,024 bytes.
- Verification windows: routine TOTP 100 steps maximum; explicit drift search 10,000 periods; HOTP 10,000 counters maximum.
- Aegis password derivation: scrypt N at most 65,536, r at most 16, p at most 4 and a 64 MiB memory policy.
- PSKC password derivation: PBKDF2 at most 2,000,000 iterations; export requires at least 100,000 iterations and a 12-character password.
- WebAuthn trace and OTP collection snapshot: 8 MiB; collection/profile count 10,000.
- QR encoder: version 10 maximum. QR decoder: 16 MiB, 4096 px per side and 16 megapixels.
- FIDO Metadata BLOB: 32 MiB, 100,000 entries and 20 signer certificates maximum.
- Camera QR scan: 60 seconds, about 10 frames/second and 1,280 px maximum processing edge.
## Not persistent storage or an identity provider
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance
The corresponding source for OTP & Passkey Tools 0.2.0 is published at:
The corresponding source for OTP & Passkey Tools 0.3.1 is published at:
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.2.0
https://git.add-ideas.de/lotobo/auth-tools/src/tag/v0.3.1
Build that tag with Node.js 22 and the exact `package-lock.json`:
+2 -1
View File
@@ -8,11 +8,12 @@ The release preparation step collates licence files for the exact locked runtime
| -------------------------------- | ------- | ---------- | ------------------------------- | ------------------------------------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 | manifest and context contract | https://git.add-ideas.de/lotobo/toolbox-sdk |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
| `@noble/hashes` | 2.3.0 | MIT | local Aegis scrypt derivation | https://github.com/paulmillr/noble-hashes |
| `jsqr` | 1.4.0 | Apache-2.0 | local QR image decoding | https://github.com/cozmo/jsQR |
| `react` | 19.2.6 | MIT | application UI | https://github.com/facebook/react |
| `react-dom` | 19.2.6 | MIT | browser rendering | https://github.com/facebook/react |
`scheduler`, distributed by the React project under MIT, is the only transitive runtime package. Exact licence texts, including jsQR's Apache-2.0 terms, are collated from the locked install into the release.
`scheduler`, distributed by the React project under MIT, is the only transitive runtime package. Exact licence texts, including jsQR's Apache-2.0 and noble-hashes' MIT terms, are collated from the locked install into the release.
## QR construction
+4 -2
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.auth-tools",
"name": "OTP & Passkey Tools",
"version": "0.2.0",
"version": "0.3.1",
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -18,7 +18,9 @@
"fido2",
"passkey",
"attestation",
"assertion"
"assertion",
"extensions",
"migration"
],
"integration": {
"contextVersion": 1,
+5 -1
View File
@@ -126,7 +126,11 @@ try {
stagedArchive,
...sourceFiles.map((file) => file.relative),
],
{ cwd: stagedTree, maxBuffer: 1024 * 1024 },
{
cwd: stagedTree,
env: { ...process.env, TZ: "UTC" },
maxBuffer: 1024 * 1024,
},
);
const archive = await readFile(stagedArchive);
const digest = createHash("sha256").update(archive).digest("hex");
+11
View File
@@ -46,6 +46,12 @@ export function HelpDialog({
hash/digit profile. The timeline and drift finder are diagnostics;
HOTP counters must remain synchronized.
</p>
<p>
Migration QR codes and plaintext exports reveal OTP seeds. Aegis and
PSKC password decryption happens locally. Collection snapshots omit
seeds but include stable fingerprints that can correlate accounts.
Camera scanning starts only after your click and stops automatically.
</p>
<h3>Passkey inspection</h3>
<p>
Inspection is offline. Verification is layered: client-data
@@ -61,6 +67,11 @@ export function HelpDialog({
Toolbox origin remains inspect-only so unrelated apps do not share its
credential namespace.
</p>
<p>
Extension results are browser/authenticator capabilities, not security
guarantees. Redacted ceremony traces retain signed evidence for replay
and may therefore still contain correlatable identifiers.
</p>
<p>
No authentication material, file, telemetry or request leaves this
tab.
+278
View File
@@ -0,0 +1,278 @@
import { useState } from "react";
import {
compareCollectionSnapshots,
createCollectionSnapshot,
parseCollectionSnapshot,
type CollectionComparison,
} from "../../otp/collection";
import {
resynchronizeHotp,
type HotpResynchronizationResult,
} from "../../otp/otp";
import type { OtpProfile } from "../../otp/profile";
function download(name: string, value: string): void {
const url = URL.createObjectURL(
new Blob([value], { type: "application/json;charset=utf-8" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
export function OtpAdvancedTools({
profile,
collection,
}: {
profile: OtpProfile;
collection: readonly OtpProfile[];
}) {
const [firstCode, setFirstCode] = useState("");
const [secondCode, setSecondCode] = useState("");
const [lookAhead, setLookAhead] = useState(100);
const [resync, setResync] = useState<
HotpResynchronizationResult | null | undefined
>();
const [baseline, setBaseline] = useState("");
const [comparison, setComparison] = useState<CollectionComparison | null>(
null,
);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const synchronize = async () => {
setBusy(true);
try {
if (profile.kind !== "hotp")
throw new Error(
"Select an HOTP credential in the credential lab first.",
);
setResync(
await resynchronizeHotp({
firstCode,
...(secondCode.trim() ? { secondCode } : {}),
secret: profile.secret,
counter: profile.counter,
digits: profile.digits,
algorithm: profile.algorithm,
lookAhead,
}),
);
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "HOTP resynchronization failed.",
);
} finally {
setBusy(false);
}
};
const exportSnapshot = async () => {
try {
const snapshot = await createCollectionSnapshot(collection);
download(
"otp-collection-snapshot.json",
`${JSON.stringify(snapshot, null, 2)}\n`,
);
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Snapshot export failed.",
);
}
};
const compare = async () => {
setBusy(true);
try {
const before = parseCollectionSnapshot(baseline);
const after = await createCollectionSnapshot(collection);
setComparison(compareCollectionSnapshots(before, after));
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Collection comparison failed.",
);
} finally {
setBusy(false);
}
};
return (
<div className="split-grid">
{error && (
<div className="notice error wide-note" role="alert">
{error}
</div>
)}
<article className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Bounded counter search</p>
<h2>HOTP resynchronization</h2>
</div>
<span className="badge">max 10,000</span>
</div>
<div className="panel-body compact-form">
<p className="hint">
Enter one observed code, or two consecutive codes for stronger
confirmation. The suggested next counter is not applied
automatically.
</p>
<label>
<span>First observed code</span>
<input
inputMode="numeric"
value={firstCode}
onChange={(event) => setFirstCode(event.target.value)}
/>
</label>
<label>
<span>Second consecutive code · recommended</span>
<input
inputMode="numeric"
value={secondCode}
onChange={(event) => setSecondCode(event.target.value)}
/>
</label>
<label>
<span>Look ahead from counter {profile.counter.toString()}</span>
<input
type="number"
min="0"
max="10000"
value={lookAhead}
onChange={(event) => setLookAhead(Number(event.target.value))}
/>
</label>
<button
type="button"
className="primary-button"
disabled={busy || profile.kind !== "hotp"}
onClick={() => void synchronize()}
>
Find counter
</button>
{profile.kind !== "hotp" && (
<p className="hint">
The selected credential is TOTP. Switch it to HOTP to use this
diagnostic.
</p>
)}
{resync === null && (
<p className="verification">
No matching sequence in the bounded window.
</p>
)}
{resync && (
<dl className="time-readout">
<div>
<dt>Matched counter</dt>
<dd>{resync.matchedCounter.toString()}</dd>
</div>
<div>
<dt>Distance</dt>
<dd>+{resync.distance}</dd>
</div>
<div>
<dt>Suggested next counter</dt>
<dd>{resync.nextCounter.toString()}</dd>
</div>
<div>
<dt>Confidence</dt>
<dd>
{resync.confidence === "consecutive-codes"
? "two consecutive codes"
: "single code only"}
</dd>
</div>
</dl>
)}
</div>
</article>
<article className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Secret-free inventory</p>
<h2>Collection comparison</h2>
</div>
<span className="badge">
{collection.length} profile{collection.length === 1 ? "" : "s"}
</span>
</div>
<div className="panel-body compact-form">
<p className="hint">
Snapshots contain labels, parameters and stable SHA-256 secret
fingerprintsnot the secrets themselves. Fingerprints can still
correlate credentials and should remain private.
</p>
<button
type="button"
className="secondary-button"
disabled={!collection.length}
onClick={() => void exportSnapshot()}
>
Export current snapshot
</button>
<label>
<span>Earlier snapshot · JSON</span>
<textarea
rows={10}
value={baseline}
onChange={(event) => setBaseline(event.target.value)}
placeholder="Paste an earlier Auth Tools collection snapshot"
/>
</label>
<button
type="button"
className="primary-button"
disabled={busy || !baseline.trim() || !collection.length}
onClick={() => void compare()}
>
Compare and plan rotation
</button>
{comparison && (
<>
<p className="verification">
{comparison.unchanged} unchanged · {comparison.changes.length}{" "}
change{comparison.changes.length === 1 ? "" : "s"}
</p>
<ul className="finding-list">
{comparison.changes.map((change, index) => (
<li
className={change.severity}
key={`${change.kind}-${change.label}-${index}`}
>
<strong>
{change.kind} · {change.label}
</strong>
<small>{change.detail}</small>
</li>
))}
</ul>
{comparison.rotationPlan.length > 0 && (
<details className="findings">
<summary>Prioritized rotation plan</summary>
<ol>
{comparison.rotationPlan.map((change, index) => (
<li key={`${change.kind}-${index}`}>
<strong>{change.label}</strong> {change.detail}
</li>
))}
</ol>
</details>
)}
</>
)}
</div>
</article>
</div>
);
}
+219 -5
View File
@@ -6,6 +6,7 @@ import {
} from "../../crypto/encoding";
import {
exportCsv,
exportGoogleMigration,
exportOtpAuthList,
importCsv,
importGoogleMigration,
@@ -49,10 +50,14 @@ import {
} from "../../otp/time";
import { qrSvg } from "../../qr/encoder";
import { decodeQrImage } from "../../qr/decoder";
import { scanQrFromCamera } from "../../qr/camera";
import {
importEncryptedAegisBackup,
importVendorBackup,
type VendorBackupFormat,
} from "../../otp/vendor-backups";
import { exportEncryptedPskc, importEncryptedPskc } from "../../otp/pskc";
import { OtpAdvancedTools } from "./OtpAdvancedTools";
type TotpTimeMode = "current" | "relative" | "absolute";
@@ -128,7 +133,7 @@ export function OtpWorkspace() {
const [verification, setVerification] = useState("");
const [error, setError] = useState("");
const [mode, setMode] = useState<
"credential" | "diagnostics" | "ocra" | "migration"
"credential" | "diagnostics" | "ocra" | "migration" | "advanced"
>("credential");
const [migrationText, setMigrationText] = useState("");
const [imported, setImported] = useState<OtpProfile[]>([]);
@@ -136,6 +141,11 @@ export function OtpWorkspace() {
const [vendorFormat, setVendorFormat] = useState<VendorBackupFormat>("aegis");
const [backupPassword, setBackupPassword] = useState("");
const [health, setHealth] = useState<CredentialHealthReport | null>(null);
const [googleParts, setGoogleParts] = useState<string[]>([]);
const [cameraOpen, setCameraOpen] = useState(false);
const [cameraBusy, setCameraBusy] = useState(false);
const cameraVideo = useRef<HTMLVideoElement>(null);
const cameraAbort = useRef<AbortController | null>(null);
const timeZones = useMemo(() => supportedTimeZones(), []);
const resolvedTime = useMemo(() => {
@@ -179,6 +189,12 @@ export function OtpWorkspace() {
const timer = window.setInterval(() => setNow(Date.now()), 500);
return () => window.clearInterval(timer);
}, []);
useEffect(
() => () => {
cameraAbort.current?.abort();
},
[],
);
useEffect(() => {
const currentGeneration = ++generationId.current;
if (profile.kind === "totp" && resolvedTime.timestampMs === null)
@@ -389,9 +405,59 @@ export function OtpWorkspace() {
);
}
};
const performVendorImport = () => {
const acceptCameraQr = (value: string) => {
setMigrationText((current) =>
current.trim().startsWith("otpauth-migration://") &&
value.startsWith("otpauth-migration://")
? `${current.trim()}\n${value}`
: value,
);
if (!value.startsWith("otpauth-migration://")) performImport(value);
};
const startCamera = () => {
setCameraOpen(true);
setCameraBusy(true);
setError("");
window.setTimeout(() => {
const video = cameraVideo.current;
if (!video) {
setCameraBusy(false);
setError("Camera preview could not be initialized.");
return;
}
const controller = new AbortController();
cameraAbort.current = controller;
void scanQrFromCamera(video, controller.signal)
.then((value) => {
acceptCameraQr(value);
setCameraOpen(false);
})
.catch((reason: unknown) => {
if (!(reason instanceof DOMException && reason.name === "AbortError"))
setError(
reason instanceof Error
? reason.message
: "Camera QR decoding failed.",
);
})
.finally(() => {
cameraAbort.current = null;
setCameraBusy(false);
});
}, 0);
};
const stopCamera = () => {
cameraAbort.current?.abort();
cameraAbort.current = null;
setCameraOpen(false);
setCameraBusy(false);
};
const performVendorImport = async () => {
try {
const result = importVendorBackup(vendorFormat, migrationText);
const result =
vendorFormat === "aegis" && backupPassword
? await importEncryptedAegisBackup(migrationText, backupPassword)
: importVendorBackup(vendorFormat, migrationText);
setImported(result.profiles);
setWarnings(result.warnings);
setHealth(null);
@@ -404,6 +470,55 @@ export function OtpWorkspace() {
);
}
};
const performPskcImport = async () => {
try {
const result = await importEncryptedPskc(migrationText, backupPassword);
setImported(result.profiles);
setWarnings(result.warnings);
setHealth(null);
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Encrypted PSKC import failed.",
);
}
};
const exportPskc = async () => {
try {
downloadText(
"otp-credentials.pskc.xml",
await exportEncryptedPskc(imported, backupPassword),
"application/xml;charset=utf-8",
);
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Encrypted PSKC export failed.",
);
}
};
const exportGoogle = () => {
try {
const result = exportGoogleMigration(imported, 1);
setGoogleParts(result.uris);
setWarnings((current) => [...new Set([...current, ...result.warnings])]);
downloadText(
"google-authenticator-migration.txt",
`${result.uris.join("\n")}\n`,
);
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Google migration export failed.",
);
}
};
const analyzeHealth = async () => {
try {
setHealth(await credentialHealthReport(imported));
@@ -475,6 +590,13 @@ export function OtpWorkspace() {
>
Import & migration
</button>
<button
type="button"
className={mode === "advanced" ? "active" : ""}
onClick={() => setMode("advanced")}
>
Resync & rotation
</button>
</div>
{error && (
<div className="notice error" role="alert">
@@ -879,6 +1001,11 @@ export function OtpWorkspace() {
/>
) : mode === "ocra" ? (
<OcraLab />
) : mode === "advanced" ? (
<OtpAdvancedTools
profile={profile}
collection={imported.length ? imported : [profile]}
/>
) : (
<div className="migration-grid">
<article className="panel">
@@ -920,7 +1047,38 @@ export function OtpWorkspace() {
}}
/>
</label>
<button
type="button"
className="secondary-button"
disabled={cameraBusy}
onClick={startCamera}
>
{cameraBusy ? "Scanning…" : "Scan with camera"}
</button>
</div>
{cameraOpen && (
<div
className="camera-scanner"
role="dialog"
aria-label="QR camera scanner"
>
<video ref={cameraVideo} aria-label="Camera preview" />
<div>
<strong>Point the camera at an OTP QR code</strong>
<span>
Video stays in this tab and stops after a match,
cancellation, or 60 seconds.
</span>
<button
type="button"
className="secondary-button"
onClick={stopCamera}
>
Cancel camera
</button>
</div>
</div>
)}
<div className="form-grid migration-options">
<label>
<span>App backup format</span>
@@ -939,9 +1097,11 @@ export function OtpWorkspace() {
<button
type="button"
className="secondary-button compact-button"
onClick={performVendorImport}
onClick={() => void performVendorImport()}
>
Import app backup
{vendorFormat === "aegis" && backupPassword
? "Decrypt Aegis backup"
: "Import app backup"}
</button>
<label className="wide">
<span>Encrypted backup password</span>
@@ -960,6 +1120,14 @@ export function OtpWorkspace() {
>
Decrypt Auth Tools backup
</button>
<button
type="button"
className="secondary-button compact-button"
disabled={!backupPassword}
onClick={() => void performPskcImport()}
>
Decrypt PSKC
</button>
</div>
</div>
</article>
@@ -1037,6 +1205,21 @@ export function OtpWorkspace() {
>
Export CSV
</button>
<button
type="button"
className="secondary-button"
onClick={exportGoogle}
>
Export Google QR batch
</button>
<button
type="button"
className="secondary-button"
disabled={backupPassword.length < 12}
onClick={() => void exportPskc()}
>
Export encrypted PSKC
</button>
<button
type="button"
className="secondary-button"
@@ -1072,6 +1255,37 @@ export function OtpWorkspace() {
)}
</div>
)}
{googleParts.length > 0 && (
<div className="google-qr-grid">
{googleParts.map((part, index) => {
let svg = "";
try {
svg = qrSvg(part);
} catch {
// The text export remains available for very large QR parts.
}
return (
<div className="credential-card" key={part}>
<strong>
Google migration part {index + 1} /{" "}
{googleParts.length}
</strong>
{svg ? (
<div
className="qr"
dangerouslySetInnerHTML={{ __html: svg }}
/>
) : (
<small>
This part exceeds the built-in QR renderer; use
the downloaded URI with another encoder.
</small>
)}
</div>
);
})}
</div>
)}
</div>
)}
</article>
@@ -0,0 +1,535 @@
import { useEffect, useMemo, useState } from "react";
import {
detectExtensionCapabilities,
extensionOutputToJson,
registrationExtensions,
authenticationExtensions,
type ExtensionCapabilities,
type ExtensionLabConfiguration,
} from "../../webauthn/extensions";
import {
createMemoryCredential,
exerciseMemoryCredential,
liveLabAvailability,
LIVE_HOST,
type MemoryCredential,
} from "../../webauthn/live";
import {
compareTraces,
parseTrace,
redactTrace,
replayTrace,
type TraceDifference,
type WebAuthnTrace,
} from "../../webauthn/trace";
function download(name: string, value: string): void {
const url = URL.createObjectURL(
new Blob([value], { type: "application/json;charset=utf-8" }),
);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.click();
URL.revokeObjectURL(url);
}
export function WebAuthnExtensionsLab() {
const [capabilities, setCapabilities] =
useState<ExtensionCapabilities | null>(null);
const [credential, setCredential] = useState<MemoryCredential | null>(null);
const [configuration, setConfiguration] = useState<ExtensionLabConfiguration>(
{
credProps: true,
prf: { enabled: false, first: "auth-tools-prf-input" },
largeBlob: { registrationSupport: "preferred" },
},
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<Record<string, unknown> | null>(null);
const [traces, setTraces] = useState<WebAuthnTrace[]>([]);
const [traceA, setTraceA] = useState("");
const [traceB, setTraceB] = useState("");
const [traceOutput, setTraceOutput] = useState("");
const [differences, setDifferences] = useState<TraceDifference[]>([]);
const availability = useMemo(() => liveLabAvailability(window.location), []);
useEffect(() => {
void detectExtensionCapabilities().then(setCapabilities);
}, []);
const requestPreview = useMemo(() => {
try {
return {
registration: extensionOutputToJson(
registrationExtensions(configuration),
),
authentication: extensionOutputToJson(
authenticationExtensions(configuration),
),
};
} catch (reason) {
return {
error:
reason instanceof Error
? reason.message
: "Invalid extension request.",
};
}
}, [configuration]);
const register = async () => {
setBusy(true);
setError("");
try {
const created = await createMemoryCredential({
userName: "extensions-lab-user",
userDisplayName: "Extensions lab user",
attestation: "none",
extensions: configuration,
});
setCredential(created);
setResult(created.extensionResults);
setTraces((current) =>
[...current, created.registrationTrace].slice(-10),
);
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Extension registration failed.",
);
} finally {
setBusy(false);
}
};
const authenticate = async () => {
if (!credential) return;
setBusy(true);
setError("");
try {
const exercised = await exerciseMemoryCredential(
credential,
true,
configuration,
);
setResult({
extensionResults: exercised.extensionResults,
verified: exercised.verified,
checks: exercised.checks,
});
setTraces((current) => [...current, exercised.trace].slice(-10));
if (exercised.verified)
setCredential((current) =>
current ? { ...current, signCount: exercised.signCount } : current,
);
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Extension authentication failed.",
);
} finally {
setBusy(false);
}
};
const exportTrace = async (trace: WebAuthnTrace, redacted: boolean) => {
const output = redacted ? await redactTrace(trace) : trace;
download(
`webauthn-${trace.kind}-${redacted ? "redacted-" : ""}${trace.recordedAt.replaceAll(":", "-")}.json`,
`${JSON.stringify(output, null, 2)}\n`,
);
};
const replay = async () => {
try {
const parsed = parseTrace(traceA);
const verification = await replayTrace(parsed);
setTraceOutput(JSON.stringify(verification, null, 2));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Trace replay failed.",
);
}
};
const compare = () => {
try {
setDifferences(compareTraces(parseTrace(traceA), parseTrace(traceB)));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Trace comparison failed.",
);
}
};
const largeBlob = configuration.largeBlob ?? {};
const prf = configuration.prf ?? {
enabled: false,
first: "auth-tools-prf-input",
};
return (
<div className="lab-stack">
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<div className="split-grid">
<article className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Client and authenticator</p>
<h2>Extension capabilities</h2>
</div>
<span
className={`badge ${availability.available ? "success" : "warning"}`}
>
{availability.available ? "Live enabled" : "Inspect only"}
</span>
</div>
<div className="panel-body compact-form">
<p className="origin-explanation">{availability.reason}</p>
{!availability.available && (
<a
className="primary-button centered-link"
href={`https://${LIVE_HOST}/`}
>
Open isolated ceremony lab
</a>
)}
<dl className="capability-list">
{capabilities &&
Object.entries(capabilities.values).map(([name, supported]) => (
<div key={name}>
<dt>{name}</dt>
<dd>{supported ? "Reported" : "Not reported"}</dd>
</div>
))}
</dl>
{capabilities?.warnings.map((warning) => (
<p className="hint" key={warning}>
{warning}
</p>
))}
<p className="hint">
A reported client capability does not guarantee that the selected
authenticator implements it. Ceremony output is authoritative.
</p>
</div>
</article>
<article className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Inspectable request</p>
<h2>Extension configuration</h2>
</div>
</div>
<div className="panel-body compact-form">
<label className="check-row">
<input
type="checkbox"
checked={Boolean(configuration.credProps)}
onChange={(event) =>
setConfiguration((current) => ({
...current,
credProps: event.target.checked,
}))
}
/>
<span>credProps · discoverable credential result</span>
</label>
<label className="check-row">
<input
type="checkbox"
checked={prf.enabled}
onChange={(event) =>
setConfiguration((current) => ({
...current,
prf: { ...prf, enabled: event.target.checked },
}))
}
/>
<span>prf · evaluate a pseudo-random function</span>
</label>
{prf.enabled && (
<>
<label>
<span>PRF first input · UTF-8 or base64url:</span>
<input
value={prf.first}
onChange={(event) =>
setConfiguration((current) => ({
...current,
prf: { ...prf, first: event.target.value },
}))
}
/>
</label>
<label>
<span>PRF optional second input</span>
<input
value={prf.second ?? ""}
onChange={(event) =>
setConfiguration((current) => ({
...current,
prf: { ...prf, second: event.target.value },
}))
}
/>
</label>
</>
)}
<label>
<span>largeBlob registration support</span>
<select
value={largeBlob.registrationSupport ?? ""}
onChange={(event) =>
setConfiguration((current) => ({
...current,
largeBlob: {
...largeBlob,
registrationSupport: (event.target.value || undefined) as
| "preferred"
| "required"
| undefined,
},
}))
}
>
<option value="">Do not request</option>
<option value="preferred">Preferred</option>
<option value="required">Required</option>
</select>
</label>
<label className="check-row">
<input
type="checkbox"
checked={Boolean(largeBlob.read)}
onChange={(event) =>
setConfiguration((current) => ({
...current,
largeBlob: {
...largeBlob,
read: event.target.checked,
write: event.target.checked ? "" : largeBlob.write,
},
}))
}
/>
<span>largeBlob read during authentication</span>
</label>
<label>
<span>largeBlob write · UTF-8 or base64url:</span>
<input
disabled={largeBlob.read}
value={largeBlob.write ?? ""}
onChange={(event) =>
setConfiguration((current) => ({
...current,
largeBlob: { ...largeBlob, write: event.target.value },
}))
}
/>
</label>
<label>
<span>appid · legacy U2F authentication URL</span>
<input
type="url"
value={configuration.appid ?? ""}
onChange={(event) =>
setConfiguration((current) => ({
...current,
appid: event.target.value,
}))
}
placeholder="https://legacy.example/app-id.json"
/>
</label>
<label>
<span>appidExclude · legacy registration exclusion URL</span>
<input
type="url"
value={configuration.appidExclude ?? ""}
onChange={(event) =>
setConfiguration((current) => ({
...current,
appidExclude: event.target.value,
}))
}
/>
</label>
<div className="button-row">
<button
type="button"
className="primary-button"
disabled={!availability.available || busy}
onClick={() => void register()}
>
{busy
? "Waiting for browser…"
: credential
? "Replace lab credential"
: "Register lab credential"}
</button>
<button
type="button"
className="secondary-button"
disabled={!credential || busy}
onClick={() => void authenticate()}
>
Authenticate
</button>
</div>
</div>
</article>
</div>
<div className="split-grid">
<article className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Before ceremony</p>
<h2>Normalized request</h2>
</div>
</div>
<pre className="diagnostic-output">
{JSON.stringify(requestPreview, null, 2)}
</pre>
</article>
<article className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">After ceremony</p>
<h2>Client extension results</h2>
</div>
</div>
{result ? (
<pre className="diagnostic-output">
{JSON.stringify(result, null, 2)}
</pre>
) : (
<div className="empty-state">
<strong>No extension output yet</strong>
<span>
Run registration or authentication on the isolated origin.
</span>
</div>
)}
</article>
</div>
<article className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Portable diagnostics</p>
<h2>Ceremony traces</h2>
</div>
<span className="badge">{traces.length} in memory</span>
</div>
<div className="panel-body compact-form">
<p className="hint">
Raw traces contain cryptographic identifiers. Redacted export
removes labels and top-level IDs, but signed authenticator data can
still be correlatable.
</p>
{traces.map((trace, index) => (
<div
className="credential-card"
key={`${trace.recordedAt}-${index}`}
>
<strong>
{trace.kind} · {trace.recordedAt}
</strong>
<span>
{Object.keys(trace.response.clientExtensionResults).length}{" "}
client extension result(s)
</span>
<div className="button-row">
<button
className="secondary-button"
type="button"
onClick={() => void exportTrace(trace, true)}
>
Export redacted
</button>
<button
className="link-button"
type="button"
onClick={() => void exportTrace(trace, false)}
>
Export raw
</button>
<button
className="link-button"
type="button"
onClick={() => setTraceA(JSON.stringify(trace, null, 2))}
>
Use for replay
</button>
</div>
</div>
))}
<div className="split-grid trace-inputs">
<label>
<span>Trace A · JSON</span>
<textarea
rows={8}
value={traceA}
onChange={(event) => setTraceA(event.target.value)}
/>
</label>
<label>
<span>Trace B · optional comparison</span>
<textarea
rows={8}
value={traceB}
onChange={(event) => setTraceB(event.target.value)}
/>
</label>
</div>
<div className="button-row">
<button
type="button"
className="primary-button"
disabled={!traceA.trim()}
onClick={() => void replay()}
>
Replay verification
</button>
<button
type="button"
className="secondary-button"
disabled={!traceA.trim() || !traceB.trim()}
onClick={compare}
>
Compare traces
</button>
</div>
{traceOutput && (
<pre className="diagnostic-output compact-output">
{traceOutput}
</pre>
)}
{differences.length > 0 && (
<ul className="finding-list">
{differences.map((difference) => (
<li key={difference.path}>
<strong>{difference.path || "root"}</strong>
<small>
{JSON.stringify(difference.left)} {" "}
{JSON.stringify(difference.right)}
</small>
</li>
))}
</ul>
)}
</div>
</article>
</div>
);
}
@@ -0,0 +1,256 @@
import { useMemo, useState } from "react";
import type { FidoMetadataSnapshot } from "../../webauthn/metadata";
import {
DEFAULT_ATTESTATION_TRUST_POLICY,
evaluateMetadataPolicy,
verifyMetadataTrust,
type MetadataPolicyResult,
type TrustVerificationResult,
} from "../../webauthn/trust-policy";
export function WebAuthnTrustPolicyLab({
metadata,
initialAaguid = "",
}: {
metadata: FidoMetadataSnapshot | null;
initialAaguid?: string;
}) {
const [root, setRoot] = useState("");
const [aaguid, setAaguid] = useState(initialAaguid);
const [asOf, setAsOf] = useState(() => new Date().toISOString().slice(0, 10));
const [version, setVersion] = useState("");
const [trust, setTrust] = useState<TrustVerificationResult | null>(null);
const [policyResult, setPolicyResult] = useState<MetadataPolicyResult | null>(
null,
);
const [error, setError] = useState("");
const [requireSignature, setRequireSignature] = useState(true);
const [requireRoot, setRequireRoot] = useState(true);
const [requireCurrent, setRequireCurrent] = useState(true);
const [requireEntry, setRequireEntry] = useState(true);
const [blockedStatuses, setBlockedStatuses] = useState(
DEFAULT_ATTESTATION_TRUST_POLICY.blockedStatuses.join(", "),
);
const [warningStatuses, setWarningStatuses] = useState(
DEFAULT_ATTESTATION_TRUST_POLICY.warningStatuses.join(", "),
);
const policy = useMemo(
() => ({
requireValidBlobSignature: requireSignature,
requirePinnedTrustRoot: requireRoot,
requireCurrentSnapshot: requireCurrent,
requireMetadataEntry: requireEntry,
blockedStatuses: blockedStatuses
.split(/[\s,]+/u)
.map((value) => value.trim().toUpperCase())
.filter(Boolean),
warningStatuses: warningStatuses
.split(/[\s,]+/u)
.map((value) => value.trim().toUpperCase())
.filter(Boolean),
}),
[
blockedStatuses,
requireCurrent,
requireEntry,
requireRoot,
requireSignature,
warningStatuses,
],
);
const verifyRoot = async () => {
if (!metadata) return;
try {
setTrust(await verifyMetadataTrust(metadata, root));
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Trust verification failed.",
);
}
};
const evaluate = () => {
if (!metadata) return;
try {
setPolicyResult(
evaluateMetadataPolicy({
snapshot: metadata,
aaguid,
asOf,
policy,
trustEstablished: trust?.trusted ?? false,
...(version ? { authenticatorVersion: Number(version) } : {}),
}),
);
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "Policy evaluation failed.",
);
}
};
return (
<div className="trust-policy-lab compact-form">
<h3>Trust root and historical policy</h3>
{!metadata ? (
<p className="hint">
Import a Metadata BLOB above to evaluate signer trust and
authenticator status.
</p>
) : (
<>
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<label>
<span>Pinned trust root · PEM certificate(s)</span>
<textarea
rows={6}
value={root}
onChange={(event) => setRoot(event.target.value)}
placeholder="-----BEGIN CERTIFICATE-----"
/>
</label>
<button
type="button"
className="secondary-button"
disabled={!root.trim()}
onClick={() => void verifyRoot()}
>
Verify signer chain
</button>
{trust && (
<ul className="finding-list">
{trust.checks.map((check) => (
<li className={check.status} key={check.name}>
<strong>{check.name}</strong>
<small>{check.detail}</small>
</li>
))}
</ul>
)}
<div className="field-grid">
<label>
<span>Authenticator AAGUID</span>
<input
value={aaguid}
onChange={(event) => setAaguid(event.target.value)}
placeholder="00000000-…"
/>
</label>
<label>
<span>Evaluate as of</span>
<input
type="date"
value={asOf}
onChange={(event) => setAsOf(event.target.value)}
/>
</label>
<label>
<span>Authenticator version · optional</span>
<input
type="number"
min="0"
step="1"
value={version}
onChange={(event) => setVersion(event.target.value)}
/>
</label>
</div>
<details className="findings policy-editor">
<summary>Policy requirements</summary>
<div className="compact-form">
<label className="check-row">
<input
type="checkbox"
checked={requireSignature}
onChange={(event) =>
setRequireSignature(event.target.checked)
}
/>
<span>Require a valid Metadata BLOB signature</span>
</label>
<label className="check-row">
<input
type="checkbox"
checked={requireRoot}
onChange={(event) => setRequireRoot(event.target.checked)}
/>
<span>Require an explicitly pinned trust root</span>
</label>
<label className="check-row">
<input
type="checkbox"
checked={requireCurrent}
onChange={(event) => setRequireCurrent(event.target.checked)}
/>
<span>Require a current snapshot at the evaluation date</span>
</label>
<label className="check-row">
<input
type="checkbox"
checked={requireEntry}
onChange={(event) => setRequireEntry(event.target.checked)}
/>
<span>Require matching authenticator metadata</span>
</label>
<label>
<span>Reject status values · comma separated</span>
<textarea
rows={3}
value={blockedStatuses}
onChange={(event) => setBlockedStatuses(event.target.value)}
/>
</label>
<label>
<span>Warn on status values · comma separated</span>
<textarea
rows={2}
value={warningStatuses}
onChange={(event) => setWarningStatuses(event.target.value)}
/>
</label>
</div>
</details>
<button
type="button"
className="primary-button"
disabled={!aaguid.trim()}
onClick={evaluate}
>
Evaluate policy
</button>
{policyResult && (
<div className="verification-report">
<strong>
{policyResult.accepted ? "Policy accepted" : "Policy rejected"}
</strong>
<ul>
{policyResult.checks.map((check) => (
<li className={check.status} key={check.name}>
<span>
{check.status === "pass"
? "✓"
: check.status === "fail"
? "×"
: "i"}
</span>
<div>
<b>{check.name}</b>
<small>{check.detail}</small>
</div>
</li>
))}
</ul>
</div>
)}
</>
)}
</div>
);
}
+16 -1
View File
@@ -34,6 +34,8 @@ import {
metadataSummary,
type FidoMetadataSnapshot,
} from "../../webauthn/metadata";
import { WebAuthnExtensionsLab } from "./WebAuthnExtensionsLab";
import { WebAuthnTrustPolicyLab } from "./WebAuthnTrustPolicyLab";
type InspectKind = "client" | "attestation" | "authenticator";
@@ -89,7 +91,7 @@ const EMPTY_ATTESTATION = {
export function WebAuthnWorkspace() {
const [mode, setMode] = useState<
"inspect" | "attestation" | "verify" | "options" | "live"
"inspect" | "attestation" | "verify" | "options" | "live" | "extensions"
>("inspect");
const [inspectKind, setInspectKind] = useState<InspectKind>("client");
const [inspectInput, setInspectInput] = useState("");
@@ -288,6 +290,13 @@ export function WebAuthnWorkspace() {
>
Live ceremony
</button>
<button
type="button"
className={mode === "extensions" ? "active" : ""}
onClick={() => setMode("extensions")}
>
Extensions & traces
</button>
</div>
{error && (
<div className="notice error" role="alert">
@@ -542,6 +551,11 @@ export function WebAuthnWorkspace() {
))}
</div>
)}
<WebAuthnTrustPolicyLab
key={attestationResult?.aaguid ?? "no-aaguid"}
metadata={metadata}
initialAaguid={attestationResult?.aaguid ?? ""}
/>
</div>
</article>
</div>
@@ -798,6 +812,7 @@ export function WebAuthnWorkspace() {
</article>
</div>
)}
{mode === "extensions" && <WebAuthnExtensionsLab />}
</section>
);
}
+259
View File
@@ -0,0 +1,259 @@
import {
bytesToArrayBuffer,
bytesToBase64Url,
utf8ToBytes,
} from "../crypto/encoding";
import type { OtpProfile } from "./profile";
export const COLLECTION_SCHEMA =
"de.add-ideas.auth-tools.otp-collection-snapshot";
export interface CollectionSnapshotProfile {
identity: string;
issuer: string;
account: string;
kind: "hotp" | "totp";
algorithm: string;
digits: number;
period: number;
epoch: number;
counter: string;
secretFingerprint: string;
}
export interface CollectionSnapshot {
schema: typeof COLLECTION_SCHEMA;
version: 1;
createdAt: string;
profiles: CollectionSnapshotProfile[];
privacy: string;
}
export type CollectionChangeKind =
| "added"
| "removed"
| "renamed"
| "secret-changed"
| "parameters-changed"
| "secret-reused";
export interface CollectionChange {
kind: CollectionChangeKind;
severity: "information" | "warning" | "critical";
label: string;
detail: string;
}
export interface CollectionComparison {
changes: CollectionChange[];
rotationPlan: CollectionChange[];
unchanged: number;
}
async function hash(value: Uint8Array): Promise<string> {
const result = new Uint8Array(
await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(value)),
);
return bytesToBase64Url(result);
}
function label(
profile: Pick<CollectionSnapshotProfile, "issuer" | "account">,
): string {
return profile.issuer
? `${profile.issuer}: ${profile.account}`
: profile.account;
}
async function identity(profile: OtpProfile): Promise<string> {
return hash(
utf8ToBytes(
`${profile.kind}\u0000${profile.issuer.trim().toLocaleLowerCase()}\u0000${profile.account.trim().toLocaleLowerCase()}`,
),
);
}
export async function createCollectionSnapshot(
profiles: readonly OtpProfile[],
): Promise<CollectionSnapshot> {
if (profiles.length > 10_000)
throw new Error("Collection snapshot exceeds 10,000 profiles.");
const result: CollectionSnapshotProfile[] = [];
for (const profile of profiles) {
result.push({
identity: await identity(profile),
issuer: profile.issuer,
account: profile.account,
kind: profile.kind,
algorithm: profile.algorithm,
digits: profile.digits,
period: profile.period,
epoch: profile.epoch ?? 0,
counter: profile.counter.toString(),
secretFingerprint: await hash(profile.secret),
});
}
return {
schema: COLLECTION_SCHEMA,
version: 1,
createdAt: new Date().toISOString(),
profiles: result,
privacy:
"No OTP secret is included. SHA-256 secret fingerprints remain stable correlation identifiers and should still be handled privately.",
};
}
export function parseCollectionSnapshot(input: string): CollectionSnapshot {
if (input.length > 8 * 1024 * 1024)
throw new Error("Collection snapshot exceeds the 8 MiB safety limit.");
let value: unknown;
try {
value = JSON.parse(input);
} catch {
throw new Error("Collection snapshot is not valid JSON.");
}
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error("Collection snapshot must be an object.");
const object = value as Record<string, unknown>;
if (
object.schema !== COLLECTION_SCHEMA ||
object.version !== 1 ||
typeof object.createdAt !== "string" ||
!Number.isFinite(Date.parse(object.createdAt)) ||
!Array.isArray(object.profiles) ||
object.profiles.length > 10_000
)
throw new Error(
"Collection snapshot has an invalid or unsupported schema.",
);
for (const [index, profile] of object.profiles.entries()) {
if (!profile || typeof profile !== "object" || Array.isArray(profile))
throw new Error(`Collection profile ${index + 1} is invalid.`);
const record = profile as Record<string, unknown>;
if (
typeof record.identity !== "string" ||
typeof record.issuer !== "string" ||
typeof record.account !== "string" ||
(record.kind !== "hotp" && record.kind !== "totp") ||
typeof record.secretFingerprint !== "string" ||
typeof record.counter !== "string" ||
!/^\d+$/u.test(record.counter)
)
throw new Error(`Collection profile ${index + 1} is incomplete.`);
}
return value as CollectionSnapshot;
}
function parameters(profile: CollectionSnapshotProfile): string {
return JSON.stringify({
kind: profile.kind,
algorithm: profile.algorithm,
digits: profile.digits,
period: profile.period,
epoch: profile.epoch,
counter: profile.counter,
});
}
export function compareCollectionSnapshots(
before: CollectionSnapshot,
after: CollectionSnapshot,
): CollectionComparison {
const changes: CollectionChange[] = [];
const beforeById = new Map(
before.profiles.map((profile) => [profile.identity, profile]),
);
const afterById = new Map(
after.profiles.map((profile) => [profile.identity, profile]),
);
const matchedBefore = new Set<string>();
const matchedAfter = new Set<string>();
let unchanged = 0;
for (const [id, oldProfile] of beforeById) {
const newProfile = afterById.get(id);
if (!newProfile) continue;
matchedBefore.add(id);
matchedAfter.add(id);
if (oldProfile.secretFingerprint !== newProfile.secretFingerprint)
changes.push({
kind: "secret-changed",
severity: "information",
label: label(newProfile),
detail:
"Secret fingerprint changed; confirm that the intended rotation completed on both sides.",
});
if (parameters(oldProfile) !== parameters(newProfile))
changes.push({
kind: "parameters-changed",
severity: "warning",
label: label(newProfile),
detail:
"OTP type, hash, digits, moving factor, period or epoch changed.",
});
if (
oldProfile.secretFingerprint === newProfile.secretFingerprint &&
parameters(oldProfile) === parameters(newProfile)
)
unchanged += 1;
}
const unmatchedBefore = before.profiles.filter(
(profile) => !matchedBefore.has(profile.identity),
);
const unmatchedAfter = after.profiles.filter(
(profile) => !matchedAfter.has(profile.identity),
);
for (const oldProfile of unmatchedBefore) {
const renamed = unmatchedAfter.find(
(profile) =>
!matchedAfter.has(profile.identity) &&
profile.secretFingerprint === oldProfile.secretFingerprint &&
parameters(profile) === parameters(oldProfile),
);
if (renamed) {
matchedBefore.add(oldProfile.identity);
matchedAfter.add(renamed.identity);
changes.push({
kind: "renamed",
severity: "information",
label: label(renamed),
detail: `Renamed from ${label(oldProfile)}.`,
});
}
}
for (const profile of before.profiles)
if (!matchedBefore.has(profile.identity))
changes.push({
kind: "removed",
severity: "warning",
label: label(profile),
detail:
"Credential is absent from the newer snapshot; verify intentional removal.",
});
for (const profile of after.profiles)
if (!matchedAfter.has(profile.identity))
changes.push({
kind: "added",
severity: "information",
label: label(profile),
detail: "Credential appears only in the newer snapshot.",
});
const byFingerprint = new Map<string, CollectionSnapshotProfile[]>();
for (const profile of after.profiles) {
const profiles = byFingerprint.get(profile.secretFingerprint) ?? [];
profiles.push(profile);
byFingerprint.set(profile.secretFingerprint, profiles);
}
for (const reused of byFingerprint.values())
if (reused.length > 1)
changes.push({
kind: "secret-reused",
severity: "critical",
label: reused.map(label).join(" · "),
detail: `${reused.length} profiles share one secret fingerprint; rotate each service to an independent secret.`,
});
const priority = { critical: 0, warning: 1, information: 2 } as const;
const rotationPlan = changes
.filter(({ kind }) => kind !== "added" && kind !== "renamed")
.sort((left, right) => priority[left.severity] - priority[right.severity]);
return { changes, rotationPlan, unchanged };
}
+132
View File
@@ -7,6 +7,11 @@ export interface ImportResult {
warnings: string[];
}
export interface GoogleMigrationExport {
uris: string[];
warnings: string[];
}
interface GoogleMigrationPart {
profiles: OtpProfile[];
warnings: string[];
@@ -38,6 +43,50 @@ function readVarint(bytes: Uint8Array, state: { offset: number }): bigint {
throw new Error("Protocol Buffer integer is too large.");
}
function encodeVarint(value: bigint): Uint8Array {
if (value < 0n || value > (1n << 64n) - 1n)
throw new Error("Protocol Buffer integer is outside uint64.");
const bytes: number[] = [];
let remaining = value;
do {
let byte = Number(remaining & 0x7fn);
remaining >>= 7n;
if (remaining) byte |= 0x80;
bytes.push(byte);
} while (remaining);
return Uint8Array.from(bytes);
}
function concatenate(parts: readonly Uint8Array[]): Uint8Array {
const output = new Uint8Array(
parts.reduce((sum, part) => sum + part.byteLength, 0),
);
let offset = 0;
for (const part of parts) {
output.set(part, offset);
offset += part.byteLength;
}
return output;
}
function protoVarint(field: number, value: bigint): Uint8Array {
return concatenate([encodeVarint(BigInt(field << 3)), encodeVarint(value)]);
}
function protoBytes(field: number, value: Uint8Array): Uint8Array {
return concatenate([
encodeVarint(BigInt((field << 3) | 2)),
encodeVarint(BigInt(value.byteLength)),
value,
]);
}
function standardBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function readProto(bytes: Uint8Array): Map<number, Array<bigint | Uint8Array>> {
const fields = new Map<number, Array<bigint | Uint8Array>>();
const state = { offset: 0 };
@@ -248,6 +297,89 @@ export function importGoogleMigrationBatch(
};
}
function googleParameter(profile: OtpProfile, index: number): Uint8Array {
if (!profile.secret.byteLength || profile.secret.byteLength > 1024)
throw new Error(`Credential ${index + 1} has an invalid secret length.`);
if (profile.digits !== 6 && profile.digits !== 8)
throw new Error(
`Credential ${index + 1} uses ${profile.digits} digits; Google migration supports 6 or 8.`,
);
if (
profile.kind === "totp" &&
(profile.period !== 30 || (profile.epoch ?? 0) !== 0)
)
throw new Error(
`Credential ${index + 1} uses a non-portable TOTP period or epoch.`,
);
const algorithm = new Map<OtpHashAlgorithm, bigint>([
["SHA-1", 1n],
["SHA-256", 2n],
["SHA-512", 3n],
]).get(profile.algorithm);
if (!algorithm)
throw new Error(`Credential ${index + 1} uses an unsupported hash.`);
const encoder = new TextEncoder();
return concatenate([
protoBytes(1, profile.secret),
protoBytes(2, encoder.encode(profile.account)),
...(profile.issuer ? [protoBytes(3, encoder.encode(profile.issuer))] : []),
protoVarint(4, algorithm),
protoVarint(5, profile.digits === 8 ? 2n : 1n),
protoVarint(6, profile.kind === "hotp" ? 1n : 2n),
...(profile.kind === "hotp" ? [protoVarint(7, profile.counter)] : []),
]);
}
export function exportGoogleMigration(
profiles: readonly OtpProfile[],
credentialsPerPart = 1,
): GoogleMigrationExport {
if (profiles.length === 0 || profiles.length > 10_000)
throw new Error("Export between 1 and 10,000 credentials.");
if (
!Number.isInteger(credentialsPerPart) ||
credentialsPerPart < 1 ||
credentialsPerPart > 10
)
throw new Error("Google migration parts may contain 1 to 10 credentials.");
const parameters = profiles.map(googleParameter);
const batchSize = Math.ceil(parameters.length / credentialsPerPart);
if (batchSize > 100)
throw new Error(
"This export would exceed 100 QR parts; increase credentials per part or export a smaller collection.",
);
const idBytes = crypto.getRandomValues(new Uint8Array(4));
const batchId = BigInt(
new DataView(idBytes.buffer).getUint32(0, false) & 0x7fffffff,
);
const uris: string[] = [];
for (let batchIndex = 0; batchIndex < batchSize; batchIndex += 1) {
const slice = parameters.slice(
batchIndex * credentialsPerPart,
(batchIndex + 1) * credentialsPerPart,
);
const payload = concatenate([
...slice.map((parameter) => protoBytes(1, parameter)),
protoVarint(2, 1n),
protoVarint(3, BigInt(batchSize)),
protoVarint(4, BigInt(batchIndex)),
protoVarint(5, batchId),
]);
const url = new URL("otpauth-migration://offline");
url.searchParams.set("data", standardBase64(payload));
uris.push(url.href);
}
const warnings = [
"Migration QR codes contain plaintext OTP secrets. Keep the screen and exported files private.",
...(credentialsPerPart > 1
? [
"Dense multi-credential QR parts may exceed some scanner capacities; one credential per part is the most compatible setting.",
]
: []),
];
return { uris, warnings };
}
function csvRows(input: string): string[][] {
if (input.length > MAX_IMPORT_BYTES)
throw new Error("CSV input is too large.");
+58
View File
@@ -22,6 +22,13 @@ export interface OtpMatch {
delta: number;
}
export interface HotpResynchronizationResult {
matchedCounter: bigint;
nextCounter: bigint;
distance: number;
confidence: "single-code" | "consecutive-codes";
}
export const MAX_HOTP_COUNTER = (1n << 64n) - 1n;
function validateSecret(secret: Uint8Array): void {
@@ -175,3 +182,54 @@ export async function verifyHotp(
}
return null;
}
export async function resynchronizeHotp(input: {
firstCode: string;
secondCode?: string;
secret: Uint8Array;
counter: bigint;
digits?: number;
algorithm?: OtpHashAlgorithm;
lookAhead?: number;
}): Promise<HotpResynchronizationResult | null> {
const digits = input.digits ?? 6;
validateDigits(digits);
const first = normalizeCode(input.firstCode, digits);
const second = input.secondCode?.trim()
? normalizeCode(input.secondCode, digits)
: undefined;
if (!first || (input.secondCode?.trim() && !second)) return null;
const lookAhead = input.lookAhead ?? 100;
if (!Number.isInteger(lookAhead) || lookAhead < 0 || lookAhead > 10_000)
throw new RangeError(
"HOTP resynchronization look-ahead must be between 0 and 10000.",
);
if (input.counter < 0n || input.counter > MAX_HOTP_COUNTER)
throw new RangeError("HOTP counter must be an unsigned 64-bit value.");
const generate = await createHotpGenerator(input);
for (let distance = 0; distance <= lookAhead; distance += 1) {
const counter = input.counter + BigInt(distance);
if (counter > MAX_HOTP_COUNTER) break;
if ((await generate(counter)) !== first) continue;
if (second) {
if (
counter === MAX_HOTP_COUNTER ||
(await generate(counter + 1n)) !== second
)
continue;
return {
matchedCounter: counter,
nextCounter: counter + 2n,
distance,
confidence: "consecutive-codes",
};
}
return {
matchedCounter: counter,
nextCounter: counter + 1n,
distance,
confidence: "single-code",
};
}
return null;
}
+449
View File
@@ -0,0 +1,449 @@
import {
bytesToArrayBuffer,
randomBytes,
utf8ToBytes,
} from "../crypto/encoding";
import type { ImportResult } from "./migration";
import type { OtpHashAlgorithm } from "./otp";
import type { OtpProfile } from "./profile";
const MAX_PSKC_BYTES = 16 * 1024 * 1024;
const PBKDF2_URI =
"http://www.rsasecurity.com/rsalabs/pkcs/schemas/pkcs-5v2-0#pbkdf2";
const AES_CBC_URIS = new Map([
[16, "http://www.w3.org/2001/04/xmlenc#aes128-cbc"],
[24, "http://www.w3.org/2001/04/xmlenc#aes192-cbc"],
[32, "http://www.w3.org/2001/04/xmlenc#aes256-cbc"],
]);
const HMAC_URIS = new Map<string, "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512">([
["http://www.w3.org/2000/09/xmldsig#hmac-sha1", "SHA-1"],
["http://www.w3.org/2001/04/xmldsig-more#hmac-sha256", "SHA-256"],
["http://www.w3.org/2001/04/xmldsig-more#hmac-sha384", "SHA-384"],
["http://www.w3.org/2001/04/xmldsig-more#hmac-sha512", "SHA-512"],
]);
export interface PskcExportOptions {
iterations?: number;
encryptionKeyBytes?: 16 | 24 | 32;
}
function base64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function decodeBase64(input: string, name: string): Uint8Array {
const normalized = input.replace(/\s/gu, "");
if (!normalized || !/^[A-Za-z0-9+/]*={0,2}$/u.test(normalized))
throw new Error(`${name} is not Base64.`);
try {
return Uint8Array.from(
atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=")),
(character) => character.charCodeAt(0),
);
} catch {
throw new Error(`${name} is not Base64.`);
}
}
function xml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function element(parent: Element, name: string): Element | undefined {
return [...parent.getElementsByTagNameNS("*", name)][0];
}
function text(parent: Element, name: string): string {
return element(parent, name)?.textContent?.trim() ?? "";
}
function positiveInteger(value: string, name: string, maximum: number): number {
if (!/^\d+$/u.test(value)) throw new Error(`${name} must be an integer.`);
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > maximum)
throw new Error(`${name} is outside the supported range.`);
return parsed;
}
async function deriveKey(
password: string,
salt: Uint8Array,
iterations: number,
bytes: number,
hash: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512",
): Promise<Uint8Array> {
const passwordBytes = utf8ToBytes(password);
let material: CryptoKey;
try {
material = await crypto.subtle.importKey(
"raw",
bytesToArrayBuffer(passwordBytes),
"PBKDF2",
false,
["deriveBits"],
);
} finally {
passwordBytes.fill(0);
}
return new Uint8Array(
await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt: bytesToArrayBuffer(salt),
iterations,
hash,
},
material,
bytes * 8,
),
);
}
async function aesEncrypt(
keyBytes: Uint8Array,
plaintext: Uint8Array,
): Promise<Uint8Array> {
const iv = randomBytes(16);
const key = await crypto.subtle.importKey(
"raw",
bytesToArrayBuffer(keyBytes),
"AES-CBC",
false,
["encrypt"],
);
const encrypted = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-CBC", iv: bytesToArrayBuffer(iv) },
key,
bytesToArrayBuffer(plaintext),
),
);
const output = new Uint8Array(iv.byteLength + encrypted.byteLength);
output.set(iv);
output.set(encrypted, iv.byteLength);
return output;
}
async function aesDecrypt(
keyBytes: Uint8Array,
encrypted: Uint8Array,
): Promise<Uint8Array> {
if (encrypted.byteLength < 32 || encrypted.byteLength % 16 !== 0)
throw new Error("Encrypted PSKC value has an invalid AES-CBC length.");
const key = await crypto.subtle.importKey(
"raw",
bytesToArrayBuffer(keyBytes),
"AES-CBC",
false,
["decrypt"],
);
return new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-CBC", iv: bytesToArrayBuffer(encrypted.slice(0, 16)) },
key,
bytesToArrayBuffer(encrypted.slice(16)),
),
);
}
async function hmac(
keyBytes: Uint8Array,
value: Uint8Array,
hash: "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512",
): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
"raw",
bytesToArrayBuffer(keyBytes),
{ name: "HMAC", hash },
false,
["sign"],
);
return new Uint8Array(
await crypto.subtle.sign("HMAC", key, bytesToArrayBuffer(value)),
);
}
function equal(left: Uint8Array, right: Uint8Array): boolean {
if (left.byteLength !== right.byteLength) return false;
let difference = 0;
for (let index = 0; index < left.byteLength; index += 1)
difference |= left[index]! ^ right[index]!;
return difference === 0;
}
function encryptedValue(
cipher: Uint8Array,
algorithm: string,
id: string,
): string {
return `<pskc:EncryptedValue Id="${id}"><xenc:EncryptionMethod Algorithm="${algorithm}"/><xenc:CipherData><xenc:CipherValue>${base64(cipher)}</xenc:CipherValue></xenc:CipherData></pskc:EncryptedValue>`;
}
function encryptedMacKey(cipher: Uint8Array, algorithm: string): string {
return `<pskc:MACKey><xenc:EncryptionMethod Algorithm="${algorithm}"/><xenc:CipherData><xenc:CipherValue>${base64(cipher)}</xenc:CipherValue></xenc:CipherData></pskc:MACKey>`;
}
export async function exportEncryptedPskc(
profiles: readonly OtpProfile[],
password: string,
options: PskcExportOptions = {},
): Promise<string> {
if (profiles.length === 0 || profiles.length > 10_000)
throw new Error("Export between 1 and 10,000 credentials.");
if (password.length < 12)
throw new Error(
"PSKC export password must contain at least 12 characters.",
);
const iterations = options.iterations ?? 600_000;
if (
!Number.isInteger(iterations) ||
iterations < 100_000 ||
iterations > 2_000_000
)
throw new Error(
"PSKC PBKDF2 iterations must be between 100,000 and 2,000,000.",
);
const keyBytes = options.encryptionKeyBytes ?? 16;
const algorithm = AES_CBC_URIS.get(keyBytes);
if (!algorithm) throw new Error("Unsupported PSKC AES key length.");
const salt = randomBytes(16);
const encryptionKey = await deriveKey(
password,
salt,
iterations,
keyBytes,
"SHA-256",
);
const macKey = randomBytes(32);
try {
const wrappedMacKey = await aesEncrypt(encryptionKey, macKey);
const packages: string[] = [];
for (const [index, profile] of profiles.entries()) {
if (!profile.secret.byteLength || profile.secret.byteLength > 1024)
throw new Error(
`Credential ${index + 1} has an invalid secret length.`,
);
const cipher = await aesEncrypt(encryptionKey, profile.secret);
const valueMac = await hmac(macKey, cipher, "SHA-256");
const algorithmUri = `urn:ietf:params:xml:ns:keyprov:pskc:${profile.kind}`;
packages.push(
`<pskc:KeyPackage><pskc:Key Id="key-${index + 1}" Algorithm="${algorithmUri}"><pskc:Issuer>${xml(profile.issuer)}</pskc:Issuer><pskc:AlgorithmParameters><pskc:ResponseFormat Length="${profile.digits}" Encoding="DECIMAL"/></pskc:AlgorithmParameters><pskc:FriendlyName>${xml(profile.account)}</pskc:FriendlyName><pskc:Data><pskc:Secret>${encryptedValue(cipher, algorithm, `ED-${index + 1}`)}<pskc:ValueMAC>${base64(valueMac)}</pskc:ValueMAC></pskc:Secret>${profile.kind === "hotp" ? `<pskc:Counter><pskc:PlainValue>${profile.counter.toString()}</pskc:PlainValue></pskc:Counter>` : `<pskc:Time><pskc:PlainValue>${profile.epoch ?? 0}</pskc:PlainValue></pskc:Time><pskc:TimeInterval><pskc:PlainValue>${profile.period}</pskc:PlainValue></pskc:TimeInterval>`}</pskc:Data><pskc:Extensions definition="urn:add-ideas:auth-tools:pskc:otp-hash"><auth:OtpHash>${profile.algorithm}</auth:OtpHash></pskc:Extensions></pskc:Key></pskc:KeyPackage>`,
);
}
const references = profiles
.map((_, index) => `<xenc:DataReference URI="#ED-${index + 1}"/>`)
.join("");
return `<?xml version="1.0" encoding="UTF-8"?><pskc:KeyContainer xmlns:pskc="urn:ietf:params:xml:ns:keyprov:pskc" xmlns:xenc="http://www.w3.org/2001/04/xmlenc#" xmlns:xenc11="http://www.w3.org/2009/xmlenc11#" xmlns:pkcs5="http://www.rsasecurity.com/rsalabs/pkcs/schemas/pkcs-5v2-0#" xmlns:auth="urn:add-ideas:auth-tools:pskc" Version="1.0"><pskc:EncryptionKey><xenc11:DerivedKey><xenc11:KeyDerivationMethod Algorithm="${PBKDF2_URI}"><pkcs5:PBKDF2-params><Salt><Specified>${base64(salt)}</Specified></Salt><IterationCount>${iterations}</IterationCount><KeyLength>${keyBytes}</KeyLength><PRF Algorithm="http://www.w3.org/2001/04/xmldsig-more#hmac-sha256"/></pkcs5:PBKDF2-params></xenc11:KeyDerivationMethod><xenc:ReferenceList>${references}</xenc:ReferenceList></xenc11:DerivedKey></pskc:EncryptionKey><pskc:MACMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#hmac-sha256">${encryptedMacKey(wrappedMacKey, algorithm)}</pskc:MACMethod>${packages.join("")}</pskc:KeyContainer>`;
} finally {
encryptionKey.fill(0);
macKey.fill(0);
}
}
function encryptedCipher(
parent: Element,
name: string,
): {
cipher: Uint8Array;
algorithm: string;
} {
const target = element(parent, name);
if (!target) throw new Error(`${name} is missing.`);
const encrypted = element(target, "EncryptedValue") ?? target;
const method = element(encrypted, "EncryptionMethod");
const cipherValue = text(encrypted, "CipherValue");
if (!method || !cipherValue)
throw new Error(`${name} has no complete EncryptedValue.`);
return {
cipher: decodeBase64(cipherValue, `${name} CipherValue`),
algorithm: method.getAttribute("Algorithm") ?? "",
};
}
function otpHash(value: string): OtpHashAlgorithm {
const normalized = value.toUpperCase().replaceAll("-", "");
if (normalized === "SHA1" || normalized === "HMACSHA1") return "SHA-1";
if (normalized === "SHA256" || normalized === "HMACSHA256") return "SHA-256";
if (normalized === "SHA512" || normalized === "HMACSHA512") return "SHA-512";
throw new Error(`Unsupported PSKC OTP suite ${value}.`);
}
export async function importEncryptedPskc(
input: string,
password: string,
): Promise<ImportResult> {
if (new TextEncoder().encode(input).byteLength > MAX_PSKC_BYTES)
throw new Error("PSKC input exceeds the 16 MiB safety limit.");
if (/<!DOCTYPE|<!ENTITY/iu.test(input))
throw new Error(
"PSKC document type and entity declarations are not allowed.",
);
if (!password) throw new Error("PSKC password is required.");
const document = new DOMParser().parseFromString(input, "application/xml");
if (document.querySelector("parsererror"))
throw new Error("PSKC is not well-formed XML.");
const root = document.documentElement;
if (
root.localName !== "KeyContainer" ||
root.namespaceURI !== "urn:ietf:params:xml:ns:keyprov:pskc"
)
throw new Error("PSKC root element or namespace is invalid.");
const derived = element(root, "DerivedKey");
const params = derived ? element(derived, "PBKDF2-params") : undefined;
const derivationMethod = derived
? element(derived, "KeyDerivationMethod")
: undefined;
if (
!derived ||
!params ||
derivationMethod?.getAttribute("Algorithm") !== PBKDF2_URI
)
throw new Error(
"PSKC does not use the supported PBKDF2 DerivedKey profile.",
);
const iterations = positiveInteger(
text(params, "IterationCount"),
"PBKDF2 iterations",
2_000_000,
);
const keyBytes = positiveInteger(
text(params, "KeyLength"),
"PBKDF2 key length",
32,
);
if (![16, 24, 32].includes(keyBytes))
throw new Error("PSKC AES key length must be 16, 24 or 32 bytes.");
const prf =
element(params, "PRF")?.getAttribute("Algorithm") ??
"http://www.w3.org/2000/09/xmldsig#hmac-sha1";
const prfHash = HMAC_URIS.get(prf);
if (!prfHash) throw new Error(`Unsupported PSKC PBKDF2 PRF ${prf}.`);
const salt = decodeBase64(text(params, "Specified"), "PBKDF2 salt");
if (salt.byteLength < 8 || salt.byteLength > 64)
throw new Error("PSKC PBKDF2 salt length is invalid.");
const encryptionKey = await deriveKey(
password,
salt,
iterations,
keyBytes,
prfHash,
);
const warnings: string[] = [];
if (iterations < 100_000)
warnings.push(
`PSKC uses only ${iterations.toLocaleString()} PBKDF2 iterations.`,
);
try {
const macMethod = element(root, "MACMethod");
if (!macMethod) throw new Error("PSKC MACMethod is missing.");
const macHash = HMAC_URIS.get(macMethod.getAttribute("Algorithm") ?? "");
if (!macHash) throw new Error("PSKC MACMethod is unsupported.");
const wrappedMac = encryptedCipher(macMethod, "MACKey");
if (AES_CBC_URIS.get(keyBytes) !== wrappedMac.algorithm)
throw new Error(
"PSKC MAC key encryption algorithm does not match its derived key.",
);
let macKey: Uint8Array;
try {
macKey = await aesDecrypt(encryptionKey, wrappedMac.cipher);
} catch (error) {
throw new Error("PSKC password is incorrect or MAC key is damaged.", {
cause: error,
});
}
try {
const packages = [...root.getElementsByTagNameNS("*", "KeyPackage")];
if (packages.length === 0 || packages.length > 10_000)
throw new Error(
"PSKC must contain between 1 and 10,000 KeyPackage elements.",
);
const profiles: OtpProfile[] = [];
for (const [index, keyPackage] of packages.entries()) {
const key = element(keyPackage, "Key");
if (!key) throw new Error(`PSKC key ${index + 1} has no Key element.`);
const algorithmUri = key.getAttribute("Algorithm") ?? "";
const kind = /:hotp$/iu.test(algorithmUri)
? "hotp"
: /:totp$/iu.test(algorithmUri)
? "totp"
: undefined;
if (!kind)
throw new Error(`PSKC key ${index + 1} is not HOTP or TOTP.`);
const encryptedSecret = encryptedCipher(key, "Secret");
if (AES_CBC_URIS.get(keyBytes) !== encryptedSecret.algorithm)
throw new Error(
`PSKC key ${index + 1} uses a mismatched encryption algorithm.`,
);
const secretElement = element(key, "Secret")!;
const expectedMac = decodeBase64(
text(secretElement, "ValueMAC"),
`PSKC key ${index + 1} ValueMAC`,
);
const actualMac = await hmac(macKey, encryptedSecret.cipher, macHash);
if (!equal(expectedMac, actualMac))
throw new Error(
`PSKC key ${index + 1} failed ValueMAC authentication.`,
);
let secret: Uint8Array;
try {
secret = await aesDecrypt(encryptionKey, encryptedSecret.cipher);
} catch (error) {
throw new Error(`PSKC key ${index + 1} decryption failed.`, {
cause: error,
});
}
if (!secret.byteLength || secret.byteLength > 1024)
throw new Error(`PSKC key ${index + 1} secret length is invalid.`);
const response = element(key, "ResponseFormat");
const digits = positiveInteger(
response?.getAttribute("Length") ?? "6",
`PSKC key ${index + 1} digits`,
10,
);
if (digits < 6)
throw new Error(`PSKC key ${index + 1} has fewer than 6 digits.`);
const counterText = text(key, "Counter") || "0";
const periodText = text(key, "TimeInterval") || "30";
const epochText = text(key, "Time") || "0";
if (!/^\d+$/u.test(counterText) || !/^\d+$/u.test(epochText))
throw new Error(
`PSKC key ${index + 1} has invalid moving-factor data.`,
);
const period = positiveInteger(
periodText,
`PSKC key ${index + 1} period`,
86_400,
);
profiles.push({
kind,
secret,
issuer: text(key, "Issuer"),
account:
text(key, "FriendlyName") ||
key.getAttribute("Id") ||
`PSKC key ${index + 1}`,
algorithm: otpHash(
text(key, "OtpHash") || text(key, "Suite") || "SHA-1",
),
digits,
period,
epoch: Number(epochText),
counter: BigInt(counterText),
extensions: new Map(),
});
}
return { profiles, warnings };
} finally {
macKey.fill(0);
}
} finally {
encryptionKey.fill(0);
}
}
+163 -8
View File
@@ -1,4 +1,10 @@
import { base32ToBytes } from "../crypto/encoding";
import { scryptAsync } from "@noble/hashes/scrypt.js";
import {
base32ToBytes,
bytesToArrayBuffer,
hexToBytes,
utf8ToBytes,
} from "../crypto/encoding";
import type { OtpHashAlgorithm } from "./otp";
import type { ImportResult } from "./migration";
import type { OtpProfile } from "./profile";
@@ -113,13 +119,8 @@ function finish(profiles: OtpProfile[], warnings: string[]): ImportResult {
return { profiles, warnings };
}
export function importAegisBackup(input: string): ImportResult {
const root = object(parseJson(input), "Aegis backup");
if (typeof root.db === "string")
throw new Error(
"This Aegis vault is encrypted. Export an unencrypted JSON copy before migration.",
);
const database = object(root.db ?? root, "Aegis database");
function parseAegisDatabase(databaseValue: unknown): ImportResult {
const database = object(databaseValue, "Aegis database");
const entries = database.entries;
if (!Array.isArray(entries))
throw new Error("Aegis backup has no entries array.");
@@ -151,6 +152,160 @@ export function importAegisBackup(input: string): ImportResult {
return finish(profiles, warnings);
}
export function importAegisBackup(input: string): ImportResult {
const root = object(parseJson(input), "Aegis backup");
if (typeof root.db === "string")
throw new Error(
"This Aegis vault is encrypted. Supply its password to decrypt it locally.",
);
return parseAegisDatabase(root.db ?? root);
}
function decodeBase64(input: string, name: string): Uint8Array {
const normalized = input.replace(/\s/gu, "");
if (!/^[A-Za-z0-9+/]*={0,2}$/u.test(normalized))
throw new Error(`${name} is not Base64.`);
try {
return Uint8Array.from(
atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=")),
(character) => character.charCodeAt(0),
);
} catch {
throw new Error(`${name} is not Base64.`);
}
}
function aesGcmPayload(ciphertext: Uint8Array, tag: Uint8Array): ArrayBuffer {
if (tag.byteLength !== 16)
throw new Error("Aegis AES-GCM tag must contain 16 bytes.");
const payload = new Uint8Array(ciphertext.byteLength + tag.byteLength);
payload.set(ciphertext);
payload.set(tag, ciphertext.byteLength);
return bytesToArrayBuffer(payload);
}
async function decryptGcm(
keyBytes: Uint8Array,
ciphertext: Uint8Array,
nonce: Uint8Array,
tag: Uint8Array,
): Promise<Uint8Array> {
if (keyBytes.byteLength !== 32 || nonce.byteLength !== 12)
throw new Error("Aegis AES-256-GCM key or nonce length is invalid.");
const key = await crypto.subtle.importKey(
"raw",
bytesToArrayBuffer(keyBytes),
"AES-GCM",
false,
["decrypt"],
);
return new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: bytesToArrayBuffer(nonce), tagLength: 128 },
key,
aesGcmPayload(ciphertext, tag),
),
);
}
export async function importEncryptedAegisBackup(
input: string,
password: string,
onProgress?: (progress: number) => void,
): Promise<ImportResult> {
if (!password) throw new Error("Aegis password is required.");
const root = object(parseJson(input), "Aegis backup");
if (typeof root.db !== "string") return parseAegisDatabase(root.db ?? root);
if (integer(root.version, 0, "Aegis vault version") !== 1)
throw new Error("Only Aegis vault container version 1 is supported.");
const header = object(root.header, "Aegis vault header");
if (!Array.isArray(header.slots) || header.slots.length > 32)
throw new Error("Aegis vault has no bounded key-slot list.");
const passwordSlots = header.slots
.map((slot, index) => object(slot, `Aegis key slot ${index + 1}`))
.filter((slot) => integer(slot.type, 0, "Aegis key-slot type") === 1);
if (!passwordSlots.length)
throw new Error("Aegis vault has no password key slot.");
const passwordBytes = utf8ToBytes(password);
let masterKey: Uint8Array | undefined;
for (const [index, slot] of passwordSlots.entries()) {
const n = integer(slot.n, 0, "Aegis scrypt N");
const r = integer(slot.r, 0, "Aegis scrypt r");
const p = integer(slot.p, 0, "Aegis scrypt p");
if (
n < 2 ||
n > 65_536 ||
(n & (n - 1)) !== 0 ||
r < 1 ||
r > 16 ||
p < 1 ||
p > 4 ||
128 * n * r > 64 * 1024 * 1024
)
throw new Error("Aegis scrypt parameters exceed the safety policy.");
const salt = hexToBytes(text(slot.salt));
if (salt.byteLength < 16 || salt.byteLength > 64)
throw new Error("Aegis scrypt salt length is invalid.");
const params = object(
slot.key_params ?? slot.params,
`Aegis key slot ${index + 1} parameters`,
);
const wrapper = await scryptAsync(passwordBytes, salt, {
N: n,
r,
p,
dkLen: 32,
asyncTick: 8,
maxmem: 64 * 1024 * 1024 + 1024,
...(onProgress ? { onProgress } : {}),
});
try {
masterKey = await decryptGcm(
wrapper,
hexToBytes(text(slot.key)),
hexToBytes(text(params.nonce)),
hexToBytes(text(params.tag)),
);
if (masterKey.byteLength !== 32)
throw new Error("Aegis master key length is invalid.");
break;
} catch {
masterKey = undefined;
} finally {
wrapper.fill(0);
}
}
passwordBytes.fill(0);
if (!masterKey)
throw new Error("Aegis password is incorrect or the key slot is damaged.");
const params = object(header.params, "Aegis database parameters");
try {
const plaintext = await decryptGcm(
masterKey,
decodeBase64(root.db, "Aegis database ciphertext"),
hexToBytes(text(params.nonce)),
hexToBytes(text(params.tag)),
);
let database: unknown;
try {
database = JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(plaintext),
);
} catch {
throw new Error("Decrypted Aegis database is not valid UTF-8 JSON.");
} finally {
plaintext.fill(0);
}
return parseAegisDatabase(database);
} catch (error) {
if (error instanceof Error && error.message.startsWith("Decrypted Aegis"))
throw error;
throw new Error("Aegis database authentication failed.", { cause: error });
} finally {
masterKey.fill(0);
}
}
export function importAndOtpBackup(input: string): ImportResult {
const parsed = parseJson(input);
if (!Array.isArray(parsed))
+129
View File
@@ -0,0 +1,129 @@
import jsQR from "jsqr";
interface BarcodeResult {
rawValue: string;
}
interface BarcodeDetectorLike {
detect(source: CanvasImageSource): Promise<BarcodeResult[]>;
}
interface BarcodeDetectorConstructor {
new (options: { formats: string[] }): BarcodeDetectorLike;
}
const MAX_SCAN_MS = 60_000;
const FRAME_INTERVAL_MS = 100;
const MAX_FRAME_EDGE = 1280;
function abortError(): DOMException {
return new DOMException("Camera scan was cancelled.", "AbortError");
}
function waitFrame(signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(abortError());
return;
}
const onAbort = (): void => {
window.clearTimeout(timer);
reject(abortError());
};
const timer = window.setTimeout(() => {
signal.removeEventListener("abort", onAbort);
resolve();
}, FRAME_INTERVAL_MS);
signal.addEventListener("abort", onAbort, { once: true });
});
}
export async function scanQrFromCamera(
video: HTMLVideoElement,
signal: AbortSignal,
): Promise<string> {
if (!globalThis.isSecureContext)
throw new Error("Camera scanning requires a secure HTTPS context.");
if (!navigator.mediaDevices?.getUserMedia)
throw new Error("This browser does not expose camera capture.");
if (signal.aborted) throw abortError();
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
height: { ideal: 720 },
},
});
const stop = (): void => {
for (const track of stream.getTracks()) track.stop();
video.pause();
video.srcObject = null;
};
signal.addEventListener("abort", stop, { once: true });
try {
if (signal.aborted) throw abortError();
video.srcObject = stream;
video.playsInline = true;
video.muted = true;
await video.play();
const Constructor = (
globalThis as typeof globalThis & {
BarcodeDetector?: BarcodeDetectorConstructor;
}
).BarcodeDetector;
const detector = Constructor
? new Constructor({ formats: ["qr_code"] })
: undefined;
const canvas = document.createElement("canvas");
let context: CanvasRenderingContext2D | null | undefined;
const decoderContext = (): CanvasRenderingContext2D | null => {
context ??= canvas.getContext("2d", {
alpha: false,
willReadFrequently: true,
});
return context;
};
if (!detector && !decoderContext())
throw new Error("This browser cannot create a QR decoder surface.");
const deadline = performance.now() + MAX_SCAN_MS;
while (performance.now() < deadline) {
if (signal.aborted) throw abortError();
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
if (detector) {
try {
const result = await detector.detect(video);
if (result[0]?.rawValue) return result[0].rawValue;
} catch {
// Continue with the bundled decoder for unsupported video sources.
}
}
const fallback = decoderContext();
if (fallback && video.videoWidth > 0 && video.videoHeight > 0) {
const scale = Math.min(
1,
MAX_FRAME_EDGE / Math.max(video.videoWidth, video.videoHeight),
);
canvas.width = Math.max(1, Math.floor(video.videoWidth * scale));
canvas.height = Math.max(1, Math.floor(video.videoHeight * scale));
fallback.drawImage(video, 0, 0, canvas.width, canvas.height);
const pixels = fallback.getImageData(
0,
0,
canvas.width,
canvas.height,
);
const result = jsQR(pixels.data, pixels.width, pixels.height, {
inversionAttempts: "attemptBoth",
});
if (result?.data) return result.data;
}
}
await waitFrame(signal);
}
throw new Error("No QR code was found within the 60-second camera limit.");
} finally {
signal.removeEventListener("abort", stop);
stop();
}
}
+115 -1
View File
@@ -271,6 +271,9 @@ textarea {
align-items: stretch;
gap: 0.45rem;
}
.button-row {
flex-wrap: wrap;
}
.input-actions input,
.verify-row input {
flex: 1;
@@ -794,6 +797,112 @@ button:disabled {
margin-block: 0.6rem;
}
.lab-stack {
display: grid;
gap: 1rem;
}
.trace-inputs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-output {
min-height: 10rem;
max-height: 24rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.7);
}
.finding-list {
display: grid;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.finding-list li {
display: grid;
gap: 0.2rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.55);
overflow-wrap: anywhere;
}
.finding-list li small {
color: var(--toolbox-muted);
}
.finding-list li.fail,
.finding-list li.critical {
border-color: color-mix(
in srgb,
var(--toolbox-danger) 50%,
var(--toolbox-border)
);
}
.finding-list li.warning {
border-color: color-mix(
in srgb,
var(--auth-warning) 50%,
var(--toolbox-border)
);
}
.finding-list li.pass {
border-color: color-mix(
in srgb,
var(--auth-success) 50%,
var(--toolbox-border)
);
}
.trust-policy-lab {
display: grid;
gap: 0.75rem;
margin-top: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--toolbox-border);
}
.trust-policy-lab h3 {
margin: 0;
font-size: 0.9rem;
}
.field-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.65rem;
}
.camera-scanner {
display: grid;
grid-template-columns: minmax(12rem, 1fr) minmax(12rem, 0.8fr);
gap: 1rem;
align-items: center;
padding: 0.75rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.75);
background: var(--toolbox-surface-soft);
}
.camera-scanner video {
width: 100%;
min-height: 10rem;
max-height: 22rem;
border-radius: calc(var(--toolbox-radius) * 0.55);
background: #05070c;
object-fit: contain;
}
.camera-scanner > div {
display: grid;
gap: 0.7rem;
}
.camera-scanner span {
color: var(--toolbox-muted);
font-size: 0.78rem;
line-height: 1.5;
}
.google-qr-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
gap: 0.7rem;
margin-top: 1rem;
}
.google-qr-grid .qr {
margin: 0.4rem auto 0;
}
.tool-dialog {
width: min(calc(100% - 2rem), 42rem);
max-height: min(46rem, calc(100vh - 2rem));
@@ -848,7 +957,12 @@ button:disabled {
}
.otp-grid,
.migration-grid,
.split-grid {
.split-grid,
.trace-inputs {
grid-template-columns: 1fr;
}
.field-grid,
.camera-scanner {
grid-template-columns: 1fr;
}
.workspace-tabs button {
+4 -2
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.auth-tools",
"name": "OTP & Passkey Tools",
"version": "0.2.0",
"version": "0.3.1",
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -18,7 +18,9 @@
"fido2",
"passkey",
"attestation",
"assertion"
"assertion",
"extensions",
"migration"
],
"integration": {
"contextVersion": 1,
+1 -1
View File
@@ -1 +1 @@
export const APPLICATION_VERSION = "0.2.0";
export const APPLICATION_VERSION = "0.3.1";
+190
View File
@@ -0,0 +1,190 @@
import {
base64UrlToBytes,
bytesToArrayBuffer,
bytesToBase64Url,
utf8ToBytes,
} from "../crypto/encoding";
export interface ExtensionCapabilities {
available: boolean;
values: Record<string, boolean>;
warnings: string[];
}
export interface ExtensionLabConfiguration {
credProps?: boolean;
appid?: string;
appidExclude?: string;
prf?: {
enabled: boolean;
first: string;
second?: string;
};
largeBlob?: {
registrationSupport?: "preferred" | "required";
read?: boolean;
write?: string;
};
}
type ExtensionInputsWithAppIdExclude = AuthenticationExtensionsClientInputs & {
appidExclude?: string;
};
function extensionBytes(value: string, name: string): ArrayBuffer {
const trimmed = value.trim();
if (!trimmed) throw new Error(`${name} must not be empty.`);
const bytes = trimmed.startsWith("base64url:")
? base64UrlToBytes(trimmed.slice("base64url:".length))
: utf8ToBytes(trimmed);
if (bytes.byteLength === 0 || bytes.byteLength > 1024)
throw new Error(`${name} must contain between 1 and 1,024 bytes.`);
return bytesToArrayBuffer(bytes);
}
function appId(value: string, name: string): string {
const trimmed = value.trim();
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
throw new Error(`${name} must be an absolute HTTPS URL.`);
}
if (parsed.protocol !== "https:" || parsed.username || parsed.password)
throw new Error(`${name} must be an absolute HTTPS URL.`);
return parsed.href;
}
export async function detectExtensionCapabilities(): Promise<ExtensionCapabilities> {
if (typeof PublicKeyCredential === "undefined") {
return {
available: false,
values: {},
warnings: ["PublicKeyCredential is unavailable in this browser."],
};
}
const getCapabilities = PublicKeyCredential.getClientCapabilities;
if (typeof getCapabilities !== "function") {
return {
available: false,
values: {},
warnings: [
"This browser does not expose getClientCapabilities(); extension support remains unknown until a ceremony is attempted.",
],
};
}
try {
const values = await getCapabilities.call(PublicKeyCredential);
return {
available: true,
values: Object.fromEntries(
Object.entries(values).sort(([left], [right]) =>
left.localeCompare(right),
),
),
warnings: [],
};
} catch (error) {
return {
available: false,
values: {},
warnings: [
error instanceof Error
? `Capability detection failed: ${error.message}`
: "Capability detection failed.",
],
};
}
}
export function registrationExtensions(
configuration: ExtensionLabConfiguration,
): AuthenticationExtensionsClientInputs {
const extensions: ExtensionInputsWithAppIdExclude = {};
if (configuration.credProps) extensions.credProps = true;
if (configuration.appidExclude)
extensions.appidExclude = appId(configuration.appidExclude, "appidExclude");
if (configuration.prf?.enabled) {
extensions.prf = {
eval: {
first: extensionBytes(configuration.prf.first, "PRF first input"),
...(configuration.prf.second?.trim()
? {
second: extensionBytes(
configuration.prf.second,
"PRF second input",
),
}
: {}),
},
};
}
if (configuration.largeBlob?.registrationSupport)
extensions.largeBlob = {
support: configuration.largeBlob.registrationSupport,
};
return extensions;
}
export function authenticationExtensions(
configuration: ExtensionLabConfiguration,
): AuthenticationExtensionsClientInputs {
const extensions: AuthenticationExtensionsClientInputs = {};
if (configuration.appid)
extensions.appid = appId(configuration.appid, "appid");
if (configuration.prf?.enabled) {
extensions.prf = {
eval: {
first: extensionBytes(configuration.prf.first, "PRF first input"),
...(configuration.prf.second?.trim()
? {
second: extensionBytes(
configuration.prf.second,
"PRF second input",
),
}
: {}),
},
};
}
if (configuration.largeBlob?.read) extensions.largeBlob = { read: true };
if (configuration.largeBlob?.write?.trim()) {
if (configuration.largeBlob.read)
throw new Error("largeBlob read and write cannot be requested together.");
extensions.largeBlob = {
write: extensionBytes(configuration.largeBlob.write, "largeBlob value"),
};
}
return extensions;
}
export function extensionOutputToJson(value: unknown): unknown {
if (value instanceof ArrayBuffer)
return {
base64url: bytesToBase64Url(new Uint8Array(value)),
bytes: value.byteLength,
};
if (ArrayBuffer.isView(value)) {
const bytes = new Uint8Array(
value.buffer,
value.byteOffset,
value.byteLength,
);
return { base64url: bytesToBase64Url(bytes), bytes: bytes.byteLength };
}
if (Array.isArray(value)) return value.map(extensionOutputToJson);
if (value && typeof value === "object")
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
extensionOutputToJson(item),
]),
);
return value;
}
export function extensionRequestToJson(
configuration: ExtensionLabConfiguration,
): Record<string, unknown> {
return extensionOutputToJson(configuration) as Record<string, unknown>;
}
+129 -7
View File
@@ -10,6 +10,13 @@ import {
type AssertionVerificationResult,
} from "./verify";
import { parseAttestationObject } from "./parser";
import {
authenticationExtensions,
extensionOutputToJson,
registrationExtensions,
type ExtensionLabConfiguration,
} from "./extensions";
import type { AuthenticationTrace, RegistrationTrace } from "./trace";
export const LIVE_HOST = "auth.toolbox.add-ideas.de";
@@ -29,6 +36,8 @@ export interface MemoryCredential {
createdAt: string;
aaguid: string;
format: string;
extensionResults: Record<string, unknown>;
registrationTrace: RegistrationTrace;
}
export function liveLabAvailability(
@@ -85,6 +94,7 @@ export async function createMemoryCredential(input: {
userDisplayName: string;
attestation: AttestationConveyancePreference;
authenticatorAttachment?: AuthenticatorAttachment;
extensions?: ExtensionLabConfiguration;
}): Promise<MemoryCredential> {
const availability = liveLabAvailability(window.location);
if (!availability.available) throw new Error(availability.reason);
@@ -113,6 +123,7 @@ export async function createMemoryCredential(input: {
? { authenticatorAttachment: input.authenticatorAttachment }
: {}),
},
extensions: registrationExtensions(input.extensions ?? {}),
};
const credential = await navigator.credentials.create({ publicKey });
if (
@@ -129,11 +140,10 @@ export async function createMemoryCredential(input: {
throw new Error(
"Registration response does not contain a credential public key.",
);
return {
id: credential.id,
publicKey: coseKeyToJson(attested.credentialPublicKey),
signCount: parsed.authenticator.signCount,
transports: (credential.response.getTransports?.() ?? []).filter(
const extensionResults = extensionOutputToJson(
credential.getClientExtensionResults(),
) as Record<string, unknown>;
const transports = (credential.response.getTransports?.() ?? []).filter(
(value): value is AuthenticatorTransport =>
[
"ble",
@@ -144,17 +154,73 @@ export async function createMemoryCredential(input: {
"smart-card",
"usb",
].includes(value),
);
const publicKeyJson = coseKeyToJson(attested.credentialPublicKey);
const registrationTrace: RegistrationTrace = {
schema: "de.add-ideas.auth-tools.webauthn-trace",
version: 1,
kind: "registration",
recordedAt: new Date().toISOString(),
request: {
challenge: bytesToBase64Url(challenge),
rp: publicKey.rp,
user: {
id: bytesToBase64Url(userId),
name: publicKey.user.name,
displayName: publicKey.user.displayName,
},
pubKeyCredParams: publicKey.pubKeyCredParams,
timeout: publicKey.timeout,
attestation: publicKey.attestation,
authenticatorSelection: publicKey.authenticatorSelection,
extensions: extensionOutputToJson(publicKey.extensions ?? {}),
},
expectations: {
challenge: bytesToBase64Url(challenge),
origin: window.location.origin,
rpId: availability.rpId,
requireUserVerification: false,
},
response: {
id: credential.id,
rawId: bytesToBase64Url(new Uint8Array(credential.rawId)),
clientDataJSON: bytesToBase64Url(
new Uint8Array(credential.response.clientDataJSON),
),
attestationObject: bytesToBase64Url(
new Uint8Array(credential.response.attestationObject),
),
transports,
clientExtensionResults: extensionResults,
},
privacy: [
"Trace is held in memory until explicitly exported.",
"Use redacted export before sharing ceremony evidence.",
],
};
return {
id: credential.id,
publicKey: publicKeyJson,
signCount: parsed.authenticator.signCount,
transports,
createdAt: new Date().toISOString(),
aaguid: attested.aaguid,
format: parsed.format,
extensionResults,
registrationTrace,
};
}
export async function exerciseMemoryCredential(
credential: MemoryCredential,
requireUserVerification: boolean,
): Promise<AssertionVerificationResult> {
extensions: ExtensionLabConfiguration = {},
): Promise<
AssertionVerificationResult & {
extensionResults: Record<string, unknown>;
trace: AuthenticationTrace;
}
> {
const availability = liveLabAvailability(window.location);
if (!availability.available) throw new Error(availability.reason);
const challenge = randomBytes(32);
@@ -170,6 +236,7 @@ export async function exerciseMemoryCredential(
transports: credential.transports,
},
],
extensions: authenticationExtensions(extensions),
};
const result = await navigator.credentials.get({ publicKey });
if (
@@ -178,7 +245,7 @@ export async function exerciseMemoryCredential(
) {
throw new Error("Browser did not return a WebAuthn assertion response.");
}
return verifyAssertion({
const verification = await verifyAssertion({
clientDataJSON: bytesToBase64Url(
new Uint8Array(result.response.clientDataJSON),
),
@@ -193,4 +260,59 @@ export async function exerciseMemoryCredential(
requireUserVerification,
previousSignCount: credential.signCount,
});
const extensionResults = extensionOutputToJson(
result.getClientExtensionResults(),
) as Record<string, unknown>;
const trace: AuthenticationTrace = {
schema: "de.add-ideas.auth-tools.webauthn-trace",
version: 1,
kind: "authentication",
recordedAt: new Date().toISOString(),
request: {
challenge: bytesToBase64Url(challenge),
rpId: publicKey.rpId,
timeout: publicKey.timeout,
userVerification: publicKey.userVerification,
allowCredentials: [
{
type: "public-key",
id: credential.id,
transports: credential.transports,
},
],
extensions: extensionOutputToJson(publicKey.extensions ?? {}),
},
expectations: {
challenge: bytesToBase64Url(challenge),
origin: window.location.origin,
rpId: availability.rpId,
requireUserVerification,
previousSignCount: credential.signCount,
},
credentialPublicKey: credential.publicKey,
response: {
id: result.id,
rawId: bytesToBase64Url(new Uint8Array(result.rawId)),
clientDataJSON: bytesToBase64Url(
new Uint8Array(result.response.clientDataJSON),
),
authenticatorData: bytesToBase64Url(
new Uint8Array(result.response.authenticatorData),
),
signature: bytesToBase64Url(new Uint8Array(result.response.signature)),
...(result.response.userHandle
? {
userHandle: bytesToBase64Url(
new Uint8Array(result.response.userHandle),
),
}
: {}),
clientExtensionResults: extensionResults,
},
privacy: [
"Trace is held in memory until explicitly exported.",
"Use redacted export before sharing ceremony evidence.",
],
};
return { ...verification, extensionResults, trace };
}
+2
View File
@@ -34,6 +34,7 @@ export interface FidoMetadataSnapshot {
entries: MetadataEntry[];
jwtAlgorithm: string;
signerCertificates: number;
signerCertificateChain: string[];
signatureVerified: boolean;
trustEstablished: false;
warnings: string[];
@@ -205,6 +206,7 @@ export async function importFidoMetadataBlob(
entries,
jwtAlgorithm: algorithm,
signerCertificates: certificates.length,
signerCertificateChain: certificates.map(bytesToBase64Url),
signatureVerified,
trustEstablished: false,
warnings,
+245
View File
@@ -0,0 +1,245 @@
import {
base64UrlToBytes,
bytesToArrayBuffer,
bytesToBase64Url,
bytesToUtf8,
utf8ToBytes,
} from "../crypto/encoding";
import {
verifyAttestation,
type AttestationVerificationResult,
} from "./attestation";
import { verifyAssertion, type AssertionVerificationResult } from "./verify";
export const TRACE_SCHEMA = "de.add-ideas.auth-tools.webauthn-trace";
export interface CeremonyExpectations {
challenge: string;
origin: string;
rpId: string;
requireUserVerification?: boolean;
previousSignCount?: number;
}
export interface RegistrationTrace {
schema: typeof TRACE_SCHEMA;
version: 1;
kind: "registration";
recordedAt: string;
request: Record<string, unknown>;
expectations: CeremonyExpectations;
response: {
id: string;
rawId: string;
clientDataJSON: string;
attestationObject: string;
transports: AuthenticatorTransport[];
clientExtensionResults: Record<string, unknown>;
};
privacy: string[];
}
export interface AuthenticationTrace {
schema: typeof TRACE_SCHEMA;
version: 1;
kind: "authentication";
recordedAt: string;
request: Record<string, unknown>;
expectations: CeremonyExpectations;
credentialPublicKey: Record<string, string | number>;
response: {
id: string;
rawId: string;
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle?: string;
clientExtensionResults: Record<string, unknown>;
};
privacy: string[];
}
export type WebAuthnTrace = RegistrationTrace | AuthenticationTrace;
export interface TraceDifference {
path: string;
left: unknown;
right: unknown;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function cloneJson<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function validateJsonShape(value: unknown): void {
const queue: Array<{ value: unknown; depth: number }> = [{ value, depth: 0 }];
let items = 0;
while (queue.length) {
const current = queue.pop()!;
items += 1;
if (items > 100_000)
throw new Error("WebAuthn trace exceeds the 100,000-item safety limit.");
if (current.depth > 64)
throw new Error("WebAuthn trace exceeds the 64-level depth limit.");
if (Array.isArray(current.value)) {
for (const item of current.value)
queue.push({ value: item, depth: current.depth + 1 });
} else if (isRecord(current.value)) {
for (const [key, item] of Object.entries(current.value)) {
if (["__proto__", "constructor", "prototype"].includes(key))
throw new Error(`WebAuthn trace contains forbidden key ${key}.`);
queue.push({ value: item, depth: current.depth + 1 });
}
}
}
}
async function pseudonym(value: string): Promise<string> {
const hash = new Uint8Array(
await crypto.subtle.digest(
"SHA-256",
bytesToArrayBuffer(utf8ToBytes(value)),
),
);
return `sha256:${bytesToBase64Url(hash)}`;
}
export function parseTrace(input: string): WebAuthnTrace {
if (input.length > 8 * 1024 * 1024)
throw new Error("WebAuthn trace exceeds the 8 MiB safety limit.");
let value: unknown;
try {
value = JSON.parse(input);
} catch {
throw new Error("WebAuthn trace is not valid JSON.");
}
validateJsonShape(value);
if (
!isRecord(value) ||
value.schema !== TRACE_SCHEMA ||
value.version !== 1 ||
(value.kind !== "registration" && value.kind !== "authentication") ||
typeof value.recordedAt !== "string" ||
!Number.isFinite(Date.parse(value.recordedAt)) ||
!isRecord(value.request) ||
!isRecord(value.expectations) ||
!isRecord(value.response)
)
throw new Error("WebAuthn trace has an unsupported or invalid schema.");
const expectations = value.expectations;
if (
typeof expectations.challenge !== "string" ||
typeof expectations.origin !== "string" ||
typeof expectations.rpId !== "string" ||
typeof value.response.clientDataJSON !== "string"
)
throw new Error("WebAuthn trace is missing ceremony bindings.");
if (value.kind === "registration") {
if (typeof value.response.attestationObject !== "string")
throw new Error("Registration trace lacks an attestation object.");
} else if (
!isRecord(value.credentialPublicKey) ||
typeof value.response.authenticatorData !== "string" ||
typeof value.response.signature !== "string"
)
throw new Error("Authentication trace lacks assertion evidence.");
return value as unknown as WebAuthnTrace;
}
export async function redactTrace(
trace: WebAuthnTrace,
): Promise<WebAuthnTrace> {
const result = cloneJson(trace);
const user = isRecord(result.request.user) ? result.request.user : undefined;
if (user) {
if (typeof user.id === "string") user.id = await pseudonym(user.id);
if (typeof user.name === "string") user.name = "[redacted]";
if (typeof user.displayName === "string") user.displayName = "[redacted]";
}
result.response.id = await pseudonym(result.response.id);
result.response.rawId = await pseudonym(result.response.rawId);
if (result.kind === "authentication" && result.response.userHandle)
result.response.userHandle = "[redacted]";
result.privacy = [
"User labels, handles and top-level credential identifiers were redacted.",
"Cryptographic ceremony blobs remain intact for offline replay and can contain stable authenticator or credential identifiers.",
"Review attestation certificates and extension output before sharing this trace.",
];
return result;
}
export async function replayTrace(
trace: WebAuthnTrace,
): Promise<AttestationVerificationResult | AssertionVerificationResult> {
if (trace.kind === "registration")
return verifyAttestation({
attestationObject: trace.response.attestationObject,
clientDataJSON: trace.response.clientDataJSON,
expectedChallenge: trace.expectations.challenge,
expectedOrigin: trace.expectations.origin,
expectedRpId: trace.expectations.rpId,
requireUserVerification: trace.expectations.requireUserVerification,
});
return verifyAssertion({
clientDataJSON: trace.response.clientDataJSON,
authenticatorData: trace.response.authenticatorData,
signature: trace.response.signature,
credentialPublicKey: JSON.stringify(trace.credentialPublicKey),
expectedChallenge: trace.expectations.challenge,
expectedOrigin: trace.expectations.origin,
expectedRpId: trace.expectations.rpId,
requireUserVerification: trace.expectations.requireUserVerification,
previousSignCount: trace.expectations.previousSignCount,
});
}
function compareValues(
left: unknown,
right: unknown,
path: string,
output: TraceDifference[],
): void {
if (Object.is(left, right)) return;
if (Array.isArray(left) && Array.isArray(right)) {
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1)
compareValues(left[index], right[index], `${path}[${index}]`, output);
return;
}
if (isRecord(left) && isRecord(right)) {
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
for (const key of [...keys].sort())
compareValues(
left[key],
right[key],
path ? `${path}.${key}` : key,
output,
);
return;
}
output.push({ path, left, right });
}
export function compareTraces(
left: WebAuthnTrace,
right: WebAuthnTrace,
): TraceDifference[] {
const differences: TraceDifference[] = [];
compareValues(left, right, "", differences);
return differences.slice(0, 1_000);
}
export function traceClientData(trace: WebAuthnTrace): Record<string, unknown> {
try {
const value = JSON.parse(
bytesToUtf8(base64UrlToBytes(trace.response.clientDataJSON)),
) as unknown;
return isRecord(value) ? value : {};
} catch {
return {};
}
}
+259
View File
@@ -0,0 +1,259 @@
import {
base64UrlToBytes,
bytesToArrayBuffer,
bytesToBase64Url,
} from "../crypto/encoding";
import {
findMetadataEntry,
type FidoMetadataSnapshot,
type MetadataStatusReport,
} from "./metadata";
import { verifyIssuedCertificate } from "./x509";
export const SEVERE_METADATA_STATUSES = [
"USER_VERIFICATION_BYPASS",
"ATTESTATION_KEY_COMPROMISE",
"USER_KEY_REMOTE_COMPROMISE",
"USER_KEY_PHYSICAL_COMPROMISE",
"REVOKED",
] as const;
export interface AttestationTrustPolicy {
requireValidBlobSignature: boolean;
requirePinnedTrustRoot: boolean;
requireCurrentSnapshot: boolean;
requireMetadataEntry: boolean;
blockedStatuses: string[];
warningStatuses: string[];
}
export const DEFAULT_ATTESTATION_TRUST_POLICY: AttestationTrustPolicy = {
requireValidBlobSignature: true,
requirePinnedTrustRoot: true,
requireCurrentSnapshot: true,
requireMetadataEntry: true,
blockedStatuses: [...SEVERE_METADATA_STATUSES],
warningStatuses: ["UPDATE_AVAILABLE", "NOT_FIDO_CERTIFIED"],
};
export interface PolicyCheck {
name: string;
status: "pass" | "fail" | "warning" | "information";
detail: string;
}
export interface TrustVerificationResult {
trusted: boolean;
rootFingerprint?: string;
checks: PolicyCheck[];
}
export interface MetadataPolicyResult {
accepted: boolean;
asOf: string;
activeStatusReports: MetadataStatusReport[];
checks: PolicyCheck[];
}
function parseCertificateText(input: string): Uint8Array[] {
if (input.length > 4 * 1024 * 1024)
throw new Error("Trust-root input exceeds the 4 MiB safety limit.");
const matches = [
...input.matchAll(
/-----BEGIN CERTIFICATE-----([A-Za-z0-9+/=\r\n\t ]+)-----END CERTIFICATE-----/gu,
),
];
const values = matches.length
? matches.map((match) => match[1]!.replace(/\s/gu, ""))
: [input.trim().replace(/\s/gu, "")];
if (values.length === 0 || values.length > 100)
throw new Error("Supply between one and 100 trust-root certificates.");
return values.map((value) => {
if (!value || !/^[A-Za-z0-9+/]*={0,2}$/u.test(value))
throw new Error("Trust root is not PEM or Base64 DER.");
try {
return Uint8Array.from(atob(value), (character) =>
character.charCodeAt(0),
);
} catch {
throw new Error("Trust root is not PEM or Base64 DER.");
}
});
}
async function fingerprint(value: Uint8Array): Promise<string> {
const digest = new Uint8Array(
await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(value)),
);
return bytesToBase64Url(digest);
}
export async function verifyMetadataTrust(
snapshot: FidoMetadataSnapshot,
trustRoots: string,
): Promise<TrustVerificationResult> {
const checks: PolicyCheck[] = [];
const chain = snapshot.signerCertificateChain.map(base64UrlToBytes);
if (!snapshot.signatureVerified)
checks.push({
name: "Metadata BLOB signature",
status: "fail",
detail: "The compact JWS signature is invalid.",
});
else
checks.push({
name: "Metadata BLOB signature",
status: "pass",
detail: "The compact JWS signature verifies with its leaf certificate.",
});
if (chain.length === 0)
return {
trusted: false,
checks: [
...checks,
{
name: "Signer chain",
status: "fail",
detail: "The snapshot contains no signer certificate chain.",
},
],
};
for (let index = 0; index + 1 < chain.length; index += 1) {
const valid = await verifyIssuedCertificate(
chain[index]!,
chain[index + 1]!,
);
checks.push({
name: `Signer chain link ${index + 1}`,
status: valid ? "pass" : "fail",
detail: valid
? "Certificate signature verifies against the next certificate."
: "Certificate signature does not verify against the next certificate.",
});
}
const roots = parseCertificateText(trustRoots);
const last = chain.at(-1)!;
const lastFingerprint = await fingerprint(last);
let matched: Uint8Array | undefined;
for (const root of roots) {
if ((await fingerprint(root)) === lastFingerprint) {
matched = root;
break;
}
try {
if (await verifyIssuedCertificate(last, root)) {
matched = root;
break;
}
} catch {
// A different key type or malformed candidate is simply not a match.
}
}
checks.push({
name: "Pinned trust root",
status: matched ? "pass" : "fail",
detail: matched
? "The signer chain terminates at an explicitly supplied trust root."
: "No supplied trust root anchors the signer chain.",
});
const trusted =
snapshot.signatureVerified &&
Boolean(matched) &&
checks.every((check) => check.status !== "fail");
return {
trusted,
...(matched ? { rootFingerprint: await fingerprint(matched) } : {}),
checks,
};
}
function statusAsOf(report: MetadataStatusReport, timestamp: number): boolean {
if (!report.effectiveDate) return true;
const effective = Date.parse(`${report.effectiveDate}T00:00:00Z`);
return Number.isFinite(effective) && effective <= timestamp;
}
export function evaluateMetadataPolicy(input: {
snapshot: FidoMetadataSnapshot;
aaguid: string;
asOf: string;
policy?: AttestationTrustPolicy;
trustEstablished?: boolean;
authenticatorVersion?: number;
}): MetadataPolicyResult {
const policy = input.policy ?? DEFAULT_ATTESTATION_TRUST_POLICY;
const timestamp = Date.parse(`${input.asOf}T23:59:59Z`);
if (!/^\d{4}-\d{2}-\d{2}$/u.test(input.asOf) || !Number.isFinite(timestamp))
throw new Error("Policy evaluation date must be YYYY-MM-DD.");
const checks: PolicyCheck[] = [];
checks.push({
name: "Metadata signature",
status:
!policy.requireValidBlobSignature || input.snapshot.signatureVerified
? "pass"
: "fail",
detail: input.snapshot.signatureVerified
? "Snapshot JWS signature is valid."
: "Snapshot JWS signature is invalid.",
});
checks.push({
name: "Trust root",
status:
!policy.requirePinnedTrustRoot || input.trustEstablished
? "pass"
: "fail",
detail: input.trustEstablished
? "Signer chain is anchored to the selected root."
: "Signer chain trust has not been established.",
});
const current =
Date.parse(`${input.snapshot.nextUpdate}T23:59:59Z`) >= timestamp;
checks.push({
name: "Snapshot currency",
status: !policy.requireCurrentSnapshot || current ? "pass" : "fail",
detail: current
? `Snapshot is current at ${input.asOf}.`
: `Snapshot expired after ${input.snapshot.nextUpdate}.`,
});
const entry = findMetadataEntry(input.snapshot, input.aaguid);
checks.push({
name: "Authenticator metadata",
status: !policy.requireMetadataEntry || entry ? "pass" : "fail",
detail: entry
? `Matched AAGUID ${input.aaguid.toLowerCase()}.`
: "No matching metadata entry was found.",
});
const dated = (entry?.statusReports ?? []).filter((report) =>
statusAsOf(report, timestamp),
);
const activeStatusReports = dated.filter(
(report) =>
report.authenticatorVersion === undefined ||
input.authenticatorVersion === undefined ||
report.authenticatorVersion === input.authenticatorVersion,
);
for (const report of activeStatusReports) {
const status = policy.blockedStatuses.includes(report.status)
? "fail"
: policy.warningStatuses.includes(report.status)
? "warning"
: "information";
checks.push({
name: `Metadata status: ${report.status}`,
status,
detail: `${report.status}${report.effectiveDate ? ` effective ${report.effectiveDate}` : ""}${report.authenticatorVersion === undefined ? "" : ` for authenticator version ${report.authenticatorVersion}`}.`,
});
}
if (dated.length !== activeStatusReports.length)
checks.push({
name: "Firmware-specific statuses",
status: "information",
detail: `${dated.length - activeStatusReports.length} status report(s) target a different supplied authenticator version.`,
});
return {
accepted: checks.every((check) => check.status !== "fail"),
asOf: input.asOf,
activeStatusReports,
checks,
};
}
+110
View File
@@ -116,6 +116,27 @@ function oidBytes(oid: string): Uint8Array {
return Uint8Array.from(output);
}
function oidString(bytes: Uint8Array): string {
if (bytes.length === 0) throw new Error("Object identifier is empty.");
const first = bytes[0]!;
const arcs = [
Math.min(2, Math.floor(first / 40)),
first < 80 ? first % 40 : first - 80,
];
let value = 0;
for (const byte of bytes.slice(1)) {
value = value * 128 + (byte & 0x7f);
if (!Number.isSafeInteger(value))
throw new Error("Object identifier is too large.");
if ((byte & 0x80) === 0) {
arcs.push(value);
value = 0;
}
}
if (value !== 0) throw new Error("Object identifier is truncated.");
return arcs.join(".");
}
export function certificateExtension(
certificate: Uint8Array,
oid: string,
@@ -241,3 +262,92 @@ export async function verifyCertificateSignature(
bytesToArrayBuffer(signed),
);
}
interface CertificateSignatureAlgorithm {
importAlgorithm: RsaHashedImportParams | EcKeyImportParams;
verifyAlgorithm: AlgorithmIdentifier | EcdsaParams;
normalizeSignature(signature: Uint8Array): Uint8Array;
}
function certificateSignatureAlgorithm(
certificate: Uint8Array,
): CertificateSignatureAlgorithm {
const outer = readDerElement(certificate, 0);
const fields = derChildren(certificate, outer);
const algorithm = fields[1];
if (!algorithm || algorithm.tag !== 0x30)
throw new Error("Certificate signature algorithm is missing.");
const oid = derChildren(certificate, algorithm)[0];
if (!oid || oid.tag !== 0x06)
throw new Error("Certificate signature algorithm OID is missing.");
const name = oidString(certificate.slice(oid.contentStart, oid.end));
const rsaHashes = new Map<string, "SHA-256" | "SHA-384" | "SHA-512">([
["1.2.840.113549.1.1.11", "SHA-256"],
["1.2.840.113549.1.1.12", "SHA-384"],
["1.2.840.113549.1.1.13", "SHA-512"],
]);
const rsaHash = rsaHashes.get(name);
if (rsaHash)
return {
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: rsaHash },
verifyAlgorithm: "RSASSA-PKCS1-v1_5",
normalizeSignature: (value) => value,
};
const ecdsa = new Map<
string,
{
curve: "P-256" | "P-384" | "P-521";
hash: "SHA-256" | "SHA-384" | "SHA-512";
size: number;
}
>([
["1.2.840.10045.4.3.2", { curve: "P-256", hash: "SHA-256", size: 32 }],
["1.2.840.10045.4.3.3", { curve: "P-384", hash: "SHA-384", size: 48 }],
["1.2.840.10045.4.3.4", { curve: "P-521", hash: "SHA-512", size: 66 }],
]).get(name);
if (ecdsa)
return {
importAlgorithm: { name: "ECDSA", namedCurve: ecdsa.curve },
verifyAlgorithm: { name: "ECDSA", hash: ecdsa.hash },
normalizeSignature: (value) => derEcdsaToRaw(value, ecdsa.size),
};
throw new Error(`Unsupported certificate signature algorithm ${name}.`);
}
export async function verifyIssuedCertificate(
certificate: Uint8Array,
issuer: Uint8Array,
): Promise<boolean> {
const outer = readDerElement(certificate, 0);
if (outer.tag !== 0x30 || outer.end !== certificate.length)
throw new Error("Certificate is not a complete DER sequence.");
const fields = derChildren(certificate, outer);
const tbs = fields[0];
const signatureValue = fields[2];
if (
!tbs ||
tbs.tag !== 0x30 ||
!signatureValue ||
signatureValue.tag !== 0x03 ||
certificate[signatureValue.contentStart] !== 0
)
throw new Error("Certificate signature fields are malformed.");
const algorithm = certificateSignatureAlgorithm(certificate);
const key = await crypto.subtle.importKey(
"spki",
bytesToArrayBuffer(certificateSpki(issuer)),
algorithm.importAlgorithm,
false,
["verify"],
);
return crypto.subtle.verify(
algorithm.verifyAlgorithm,
key,
bytesToArrayBuffer(
algorithm.normalizeSignature(
certificate.slice(signatureValue.contentStart + 1, signatureValue.end),
),
),
bytesToArrayBuffer(certificate.slice(tbs.start, tbs.end)),
);
}
+34 -2
View File
@@ -48,14 +48,46 @@ test("computes an RFC OCRA vector and decodes client data", async ({
);
});
test("shared origin exposes inspection but not live credential creation", async ({
test("localhost development enables live credential creation", async ({
page,
}) => {
await page.goto("/deep/nested/auth/");
await page.getByRole("button", { name: /WebAuthn \/ Passkeys/ }).click();
await page.getByRole("button", { name: "Live ceremony" }).click();
await expect(page.getByText("Inspect only")).toBeVisible();
await expect(page.getByText("Live enabled")).toBeVisible();
await expect(
page.getByRole("button", { name: "Create test credential" }),
).toBeEnabled();
});
test("exposes the advanced OTP and WebAuthn laboratories", async ({ page }) => {
await page.goto("/deep/nested/auth/");
await page.getByRole("button", { name: "Resync & rotation" }).click();
await expect(
page.getByRole("heading", { name: "HOTP resynchronization" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Collection comparison" }),
).toBeVisible();
await page.getByRole("button", { name: "Import & migration" }).click();
await expect(
page.getByRole("button", { name: "Scan with camera" }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Decrypt PSKC" }),
).toBeDisabled();
await page.getByRole("button", { name: /WebAuthn \/ Passkeys/ }).click();
await page.getByRole("button", { name: "Extensions & traces" }).click();
await expect(
page.getByRole("heading", { name: "Extension configuration" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Ceremony traces" }),
).toBeVisible();
await page.getByRole("button", { name: "Attestation verifier" }).click();
await expect(
page.getByRole("heading", { name: "Trust root and historical policy" }),
).toBeVisible();
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import {
compareCollectionSnapshots,
createCollectionSnapshot,
parseCollectionSnapshot,
} from "../../src/otp/collection";
import type { OtpProfile } from "../../src/otp/profile";
function profile(account: string, secret: string): OtpProfile {
return {
kind: "totp",
issuer: "Example",
account,
secret: utf8ToBytes(secret),
algorithm: "SHA-1",
digits: 6,
period: 30,
counter: 0n,
extensions: new Map(),
};
}
describe("OTP collection snapshots", () => {
it("exports fingerprints without secrets", async () => {
const snapshot = await createCollectionSnapshot([
profile("alice", "top-secret-value"),
]);
const serialized = JSON.stringify(snapshot);
expect(serialized).not.toContain("top-secret-value");
expect(parseCollectionSnapshot(serialized)).toEqual(snapshot);
});
it("detects renames, parameter changes and secret reuse", async () => {
const before = await createCollectionSnapshot([
profile("alice", "secret-a"),
profile("bob", "secret-b"),
]);
const bob = profile("bob", "secret-b");
bob.digits = 8;
const after = await createCollectionSnapshot([
profile("alice-renamed", "secret-a"),
bob,
profile("carol", "secret-b"),
]);
const result = compareCollectionSnapshots(before, after);
expect(result.changes.map(({ kind }) => kind)).toEqual(
expect.arrayContaining([
"renamed",
"parameters-changed",
"added",
"secret-reused",
]),
);
expect(result.rotationPlan[0]?.kind).toBe("secret-reused");
});
});
+24
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
exportGoogleMigration,
exportCsv,
importCsv,
importGoogleMigrationBatch,
@@ -115,4 +116,27 @@ describe("OTP migrations", () => {
importGoogleMigrationBatch([googlePart(0), googlePart(1, 2, 99)]),
).toThrow(/different batches/iu);
});
it("exports and reassembles Google migration batches", () => {
const portable = { ...profile, period: 30 };
const exported = exportGoogleMigration(
[portable, { ...portable, account: "bob" }],
1,
);
expect(exported.uris).toHaveLength(2);
const imported = importGoogleMigrationBatch(exported.uris);
expect(imported.profiles.map(({ account }) => account)).toEqual([
profile.account,
"bob",
]);
expect(imported.profiles[0]).toMatchObject({
algorithm: "SHA-256",
digits: 8,
period: 30,
});
});
it("refuses lossy Google exports", () => {
expect(() => exportGoogleMigration([profile])).toThrow(/period or epoch/u);
});
});
+28
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import {
hotp,
resynchronizeHotp,
totp,
totpCounter,
verifyHotp,
@@ -41,6 +42,33 @@ describe("HOTP", () => {
});
expect(match).toMatchObject({ counter: 4n, delta: 3 });
});
it("resynchronizes with one or two bounded consecutive codes", async () => {
const secret = utf8ToBytes("12345678901234567890");
await expect(
resynchronizeHotp({
firstCode: "338314",
secondCode: "254676",
secret,
counter: 1n,
lookAhead: 10,
}),
).resolves.toEqual({
matchedCounter: 4n,
nextCounter: 6n,
distance: 3,
confidence: "consecutive-codes",
});
await expect(
resynchronizeHotp({
firstCode: "338314",
secondCode: "000000",
secret,
counter: 1n,
lookAhead: 10,
}),
).resolves.toBeNull();
});
});
describe("TOTP", () => {
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import { exportEncryptedPskc, importEncryptedPskc } from "../../src/otp/pskc";
import type { OtpProfile } from "../../src/otp/profile";
const profiles: OtpProfile[] = [
{
kind: "totp",
account: "alice@example.test",
issuer: "Example & Sons",
secret: utf8ToBytes("12345678901234567890"),
algorithm: "SHA-256",
digits: 8,
period: 45,
epoch: 10,
counter: 0n,
extensions: new Map(),
},
{
kind: "hotp",
account: "hardware token",
issuer: "Example",
secret: utf8ToBytes("abcdefghijklmnopqrst"),
algorithm: "SHA-1",
digits: 6,
period: 30,
counter: 1042n,
extensions: new Map(),
},
];
describe("encrypted PSKC", () => {
it("round-trips authenticated password-encrypted collections", async () => {
const exported = await exportEncryptedPskc(
profiles,
"correct horse battery staple",
{ iterations: 100_000 },
);
expect(exported).toContain("PBKDF2-params");
expect(exported).toContain(
'Algorithm="http://www.rsasecurity.com/rsalabs/pkcs/schemas/pkcs-5v2-0#pbkdf2"',
);
expect(exported).toContain("<pskc:MACKey><xenc:EncryptionMethod");
expect(exported).toContain('<pskc:EncryptedValue Id="ED-1">');
expect(exported).not.toContain("12345678901234567890");
const result = await importEncryptedPskc(
exported,
"correct horse battery staple",
);
expect(result.profiles).toHaveLength(2);
expect(result.profiles[0]).toMatchObject({
kind: "totp",
account: "alice@example.test",
issuer: "Example & Sons",
algorithm: "SHA-256",
digits: 8,
period: 45,
epoch: 10,
});
expect(result.profiles[1]).toMatchObject({
kind: "hotp",
counter: 1042n,
});
expect([...result.profiles[0]!.secret]).toEqual([...profiles[0]!.secret]);
});
it("rejects wrong passwords and unauthenticated changes", async () => {
const exported = await exportEncryptedPskc(
profiles.slice(0, 1),
"correct horse battery staple",
{ iterations: 100_000 },
);
await expect(
importEncryptedPskc(exported, "incorrect password"),
).rejects.toThrow(/incorrect|damaged/u);
const tampered = exported.replace(
/<pskc:ValueMAC>([^<])/u,
(_match, first: string) => `<pskc:ValueMAC>${first === "A" ? "B" : "A"}`,
);
await expect(
importEncryptedPskc(tampered, "correct horse battery staple"),
).rejects.toThrow(/ValueMAC/u);
});
it("rejects XML entity declarations", async () => {
await expect(
importEncryptedPskc(
'<!DOCTYPE x [<!ENTITY e SYSTEM "file:///etc/passwd">]><x/>',
"password",
),
).rejects.toThrow(/entity/u);
});
});
+96
View File
@@ -1,13 +1,46 @@
import { describe, expect, it } from "vitest";
import {
importAegisBackup,
importEncryptedAegisBackup,
importAndOtpBackup,
importFreeOtpBackup,
importTwoFasBackup,
} from "../../src/otp/vendor-backups";
import { scrypt } from "@noble/hashes/scrypt.js";
import {
bytesToArrayBuffer,
bytesToHex,
utf8ToBytes,
} from "../../src/crypto/encoding";
const secret = "JBSWY3DPEHPK3PXP";
async function encryptGcm(
keyBytes: Uint8Array,
plaintext: Uint8Array,
nonce: Uint8Array,
): Promise<{ ciphertext: Uint8Array; tag: Uint8Array }> {
const key = await crypto.subtle.importKey(
"raw",
bytesToArrayBuffer(keyBytes),
"AES-GCM",
false,
["encrypt"],
);
const output = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: bytesToArrayBuffer(nonce) },
key,
bytesToArrayBuffer(plaintext),
),
);
return { ciphertext: output.slice(0, -16), tag: output.slice(-16) };
}
function base64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
describe("vendor OTP backup importers", () => {
it("imports Aegis plaintext entries and skips non-portable token types", () => {
const result = importAegisBackup(
@@ -90,4 +123,67 @@ describe("vendor OTP backup importers", () => {
importAegisBackup(JSON.stringify({ db: "ciphertext", header: {} })),
).toThrow(/encrypted/iu);
});
it("decrypts authenticated Aegis password vaults locally", async () => {
const password = "correct horse battery staple";
const salt = Uint8Array.from({ length: 16 }, (_, index) => index + 1);
const masterKey = Uint8Array.from(
{ length: 32 },
(_, index) => 255 - index,
);
const wrappingKey = scrypt(password, salt, {
N: 16,
r: 8,
p: 1,
dkLen: 32,
maxmem: 1024 * 1024,
});
const slotNonce = Uint8Array.from({ length: 12 }, (_, index) => index + 20);
const dbNonce = Uint8Array.from({ length: 12 }, (_, index) => index + 40);
const wrapped = await encryptGcm(wrappingKey, masterKey, slotNonce);
const database = utf8ToBytes(
JSON.stringify({
entries: [
{
type: "totp",
name: "alice",
issuer: "Example",
info: { secret, algo: "SHA1", digits: 6, period: 30 },
},
],
}),
);
const encrypted = await encryptGcm(masterKey, database, dbNonce);
const vault = JSON.stringify({
version: 1,
header: {
slots: [
{
type: 1,
n: 16,
r: 8,
p: 1,
salt: bytesToHex(salt),
key: bytesToHex(wrapped.ciphertext),
key_params: {
nonce: bytesToHex(slotNonce),
tag: bytesToHex(wrapped.tag),
},
},
],
params: { nonce: bytesToHex(dbNonce), tag: bytesToHex(encrypted.tag) },
},
db: base64(encrypted.ciphertext),
});
await expect(
importEncryptedAegisBackup(vault, password),
).resolves.toMatchObject({
profiles: [
expect.objectContaining({ account: "alice", issuer: "Example" }),
],
});
await expect(importEncryptedAegisBackup(vault, "wrong")).rejects.toThrow(
/incorrect/u,
);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { scanQrFromCamera } from "../../src/qr/camera";
describe("opt-in camera QR scanning", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("requests only video and stops every track after a match", async () => {
const stop = vi.fn();
const getUserMedia = vi.fn().mockResolvedValue({
getTracks: () => [{ stop }],
});
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia },
});
vi.stubGlobal("isSecureContext", true);
vi.stubGlobal(
"BarcodeDetector",
class {
detect = vi
.fn()
.mockResolvedValue([{ rawValue: "otpauth://totp/Test" }]);
},
);
const video = document.createElement("video");
vi.spyOn(video, "play").mockResolvedValue();
vi.spyOn(video, "pause").mockImplementation(() => undefined);
Object.defineProperty(video, "readyState", {
configurable: true,
value: HTMLMediaElement.HAVE_CURRENT_DATA,
});
await expect(
scanQrFromCamera(video, new AbortController().signal),
).resolves.toBe("otpauth://totp/Test");
expect(getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({ audio: false, video: expect.any(Object) }),
);
expect(stop).toHaveBeenCalledOnce();
expect(video.srcObject).toBeNull();
});
it("does not request permission after prior cancellation", async () => {
const getUserMedia = vi.fn();
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia },
});
vi.stubGlobal("isSecureContext", true);
const controller = new AbortController();
controller.abort();
await expect(
scanQrFromCamera(document.createElement("video"), controller.signal),
).rejects.toMatchObject({ name: "AbortError" });
expect(getUserMedia).not.toHaveBeenCalled();
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
authenticationExtensions,
extensionOutputToJson,
registrationExtensions,
} from "../../src/webauthn/extensions";
describe("WebAuthn extension inputs", () => {
it("builds registration inputs without silently changing binary values", () => {
const result = registrationExtensions({
credProps: true,
appidExclude: "https://legacy.example.test/app-id.json",
prf: { enabled: true, first: "base64url:AQID", second: "label" },
largeBlob: { registrationSupport: "required" },
}) as AuthenticationExtensionsClientInputs & { appidExclude: string };
expect(result.credProps).toBe(true);
expect(result.appidExclude).toBe("https://legacy.example.test/app-id.json");
expect(new Uint8Array(result.prf!.eval!.first as ArrayBuffer)).toEqual(
Uint8Array.of(1, 2, 3),
);
expect(result.largeBlob).toEqual({ support: "required" });
});
it("rejects invalid AppID and conflicting largeBlob operations", () => {
expect(() =>
registrationExtensions({ appidExclude: "http://unsafe" }),
).toThrow(/HTTPS/u);
expect(() =>
authenticationExtensions({
largeBlob: { read: true, write: "payload" },
}),
).toThrow(/cannot be requested together/u);
});
it("serializes extension buffers for inspection", () => {
expect(extensionOutputToJson({ result: Uint8Array.of(1, 2, 3) })).toEqual({
result: { base64url: "AQID", bytes: 3 },
});
});
});
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { liveLabAvailability } from "../../src/webauthn/live";
describe("live WebAuthn origin isolation", () => {
afterEach(() => vi.unstubAllGlobals());
it("rejects the shared Portal origin", () => {
vi.stubGlobal("isSecureContext", true);
expect(
liveLabAvailability({
hostname: "toolbox.add-ideas.de",
origin: "https://toolbox.add-ideas.de",
}),
).toMatchObject({ available: false, rpId: "toolbox.add-ideas.de" });
});
it("accepts only the dedicated production host and local development", () => {
vi.stubGlobal("isSecureContext", true);
expect(
liveLabAvailability({
hostname: "auth.toolbox.add-ideas.de",
origin: "https://auth.toolbox.add-ideas.de",
}).available,
).toBe(true);
expect(
liveLabAvailability({
hostname: "127.0.0.1",
origin: "http://127.0.0.1:4173",
}).available,
).toBe(true);
});
it("requires a secure context on every host", () => {
vi.stubGlobal("isSecureContext", false);
expect(
liveLabAvailability({
hostname: "auth.toolbox.add-ideas.de",
origin: "http://auth.toolbox.add-ideas.de",
}),
).toMatchObject({
available: false,
reason: "WebAuthn requires a secure context.",
});
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
compareTraces,
parseTrace,
redactTrace,
type AuthenticationTrace,
} from "../../src/webauthn/trace";
function trace(): AuthenticationTrace {
return {
schema: "de.add-ideas.auth-tools.webauthn-trace",
version: 1,
kind: "authentication",
recordedAt: "2026-08-19T12:00:00.000Z",
request: {
user: { id: "user-id", name: "Alice", displayName: "Alice Example" },
extensions: { prf: true },
},
expectations: {
challenge: "AQID",
origin: "https://auth.example.test",
rpId: "auth.example.test",
},
credentialPublicKey: { "1": 2, "3": -7, "-1": 1 },
response: {
id: "credential-id",
rawId: "credential-id",
clientDataJSON: "e30",
authenticatorData: "AQID",
signature: "BAUG",
userHandle: "user-handle",
clientExtensionResults: { prf: { enabled: true } },
},
privacy: [],
};
}
describe("WebAuthn ceremony traces", () => {
it("parses the bounded versioned schema", () => {
expect(parseTrace(JSON.stringify(trace()))).toMatchObject({
kind: "authentication",
version: 1,
});
expect(() => parseTrace('{"version":2}')).toThrow(/schema/u);
let nested: Record<string, unknown> = {};
for (let depth = 0; depth < 70; depth += 1) nested = { nested };
expect(() =>
parseTrace(JSON.stringify({ ...trace(), request: nested })),
).toThrow(/depth limit/u);
});
it("redacts labels and top-level identifiers while preserving evidence", async () => {
const redacted = await redactTrace(trace());
expect(redacted.response.id).toMatch(/^sha256:/u);
expect(redacted.response.clientDataJSON).toBe("e30");
expect(
redacted.kind === "authentication" && redacted.response.userHandle,
).toBe("[redacted]");
expect((redacted.request.user as Record<string, string>).displayName).toBe(
"[redacted]",
);
});
it("compares nested trace state", () => {
const right = trace();
right.expectations.rpId = "other.example.test";
right.response.clientExtensionResults = { prf: { enabled: false } };
expect(compareTraces(trace(), right).map(({ path }) => path)).toEqual([
"expectations.rpId",
"response.clientExtensionResults.prf.enabled",
]);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { FidoMetadataSnapshot } from "../../src/webauthn/metadata";
import {
DEFAULT_ATTESTATION_TRUST_POLICY,
evaluateMetadataPolicy,
} from "../../src/webauthn/trust-policy";
const AAGUID = "00000000-0000-0000-0000-000000000001";
function snapshot(): FidoMetadataSnapshot {
return {
sequenceNumber: 42,
nextUpdate: "2027-01-01",
entries: [
{
aaguid: AAGUID,
statusReports: [
{ status: "FIDO_CERTIFIED", effectiveDate: "2025-01-01" },
{
status: "USER_VERIFICATION_BYPASS",
effectiveDate: "2026-04-01",
authenticatorVersion: 7,
},
],
},
],
jwtAlgorithm: "RS256",
signerCertificates: 1,
signerCertificateChain: ["AA"],
signatureVerified: true,
trustEstablished: false,
warnings: [],
};
}
describe("attestation metadata policy", () => {
it("evaluates historical effective dates", () => {
const result = evaluateMetadataPolicy({
snapshot: snapshot(),
aaguid: AAGUID,
asOf: "2026-03-01",
trustEstablished: true,
});
expect(result.accepted).toBe(true);
expect(result.activeStatusReports.map(({ status }) => status)).toEqual([
"FIDO_CERTIFIED",
]);
});
it("blocks severe statuses for the selected firmware", () => {
const result = evaluateMetadataPolicy({
snapshot: snapshot(),
aaguid: AAGUID,
asOf: "2026-08-19",
authenticatorVersion: 7,
trustEstablished: true,
});
expect(result.accepted).toBe(false);
expect(result.checks).toContainEqual(
expect.objectContaining({
name: "Metadata status: USER_VERIFICATION_BYPASS",
status: "fail",
}),
);
});
it("allows transparent policy overrides", () => {
const result = evaluateMetadataPolicy({
snapshot: { ...snapshot(), signatureVerified: false },
aaguid: "ffffffff-ffff-ffff-ffff-ffffffffffff",
asOf: "2028-01-01",
policy: {
...DEFAULT_ATTESTATION_TRUST_POLICY,
requireValidBlobSignature: false,
requirePinnedTrustRoot: false,
requireCurrentSnapshot: false,
requireMetadataEntry: false,
},
});
expect(result.accepted).toBe(true);
});
});
+2
View File
@@ -11,6 +11,8 @@ export default defineConfig({
setupFiles: "./src/test/setup.ts",
css: true,
restoreMocks: true,
pool: "forks",
maxWorkers: 1,
exclude: ["tests/browser/**", "node_modules/**", "dist/**"],
},
});