4 Commits
64 changed files with 8496 additions and 193 deletions
+34
View File
@@ -2,6 +2,40 @@
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.
- Added DST gap rejection and earlier/later disambiguation for repeated wall-clock times, with effective local time, UTC instant, Unix seconds and TOTP counter diagnostics.
- Added an OTP timeline, bounded clock-drift search, JSON interoperability vectors, and custom RFC 6238 T0 support with provisioning-portability warnings.
- Added complete Google Authenticator multi-QR batch assembly and explicit Aegis, 2FAS, andOTP and legacy FreeOTP JSON importers.
- Added the Apache-2.0 jsQR decoder as a bundled fallback when `BarcodeDetector` is missing or cannot decode an image.
- Added collection health reports for reused secrets, duplicate labels, short secrets and non-default timing.
- Added a versioned encrypted-backup format using PBKDF2-HMAC-SHA-256 and authenticated AES-256-GCM, with no persistent browser storage.
- Added layered WebAuthn registration verification for none, packed/self, FIDO U2F, TPM 2.0, Android Key and Apple anonymous attestation statements.
- Added explicit local import and JWS verification of FIDO Metadata Service v3 snapshots, while keeping signer-chain trust separate and unestablished by default.
- Updated the transitive `nanoid` build dependency to 3.3.18; `npm audit` reports no known vulnerabilities.
## 0.1.0 - 2026-08-19
- Initial HOTP, TOTP and OCRA credential laboratory with official RFC-vector coverage.
+1 -1
View File
@@ -2,4 +2,4 @@
OTP & Passkey Tools is `GPL-3.0-or-later`; the full project licence is at `../LICENSE`.
`THIRD_PARTY_NOTICES.md` identifies shipped packages and adapted algorithms. Release preparation collates the exact installed runtime package licence files into `npm-runtime-licenses.txt`. `QRCode-MIT.txt` preserves the licence for the QR construction algorithm rewritten in this project.
`THIRD_PARTY_NOTICES.md` identifies shipped packages and adapted algorithms. Release preparation collates the exact installed runtime package licence files, including jsQR's Apache-2.0 licence, into `npm-runtime-licenses.txt`. `QRCode-MIT.txt` preserves the licence for the QR construction algorithm rewritten in this project.
+21 -9
View File
@@ -4,16 +4,26 @@ A production-oriented, local-first browser workbench for OTP credentials and Web
Authentication material stays in the active tab. The application has no backend, telemetry, automatic network lookup, service worker, cookie, local-storage credential store, or IndexedDB database.
## Included in 0.1.0
## Current source capabilities
- RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes.
- Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder.
- 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, Google Authenticator migration QR payloads, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Encrypted PSKC is rejected rather than guessed.
- Explicit URI-list and CSV export with an unencrypted-secret warning.
- 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, 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
@@ -25,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 when the browser implements `BarcodeDetector`; 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.
@@ -44,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.1.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
@@ -61,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 structures are decoded, but 0.1.0 does not establish attestation trust or silently download FIDO Metadata Service data. Assertion signatures are verified only with the explicitly supplied/created credential key.
- Google multi-QR batches must all be supplied; the current UI reviews one payload at a time. Google migration export and encrypted PSKC decryption are not claimed.
- CSV and URI exports contain raw OTP seeds. There is no encrypted vault format in this app.
- 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
+20 -8
View File
@@ -4,14 +4,17 @@
All inputs are processed in the browser tab. There are no application `fetch`, WebSocket, beacon, analytics, service-worker, credential-storage or remote-metadata paths. OTP secrets, OCRA PIN input, imported files, WebAuthn responses and live test credential metadata are React/session memory and disappear on reload or **Clear session**.
Downloads and clipboard writes require a user action. Exports are not encrypted. The UI masks OTP secrets and provisioning URIs by default, but masking is not memory protection.
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.
- Signature verification establishes consistency with the supplied COSE public key, not account ownership or attestation trust.
- 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,31 @@ Downloads and clipboard writes require a user action. Exports are not encrypted.
## Cryptography
HMAC, digest, random generation, public-key import and signature verification use the browser Web Cryptography API. No custom cryptographic primitive is implemented. OTP dynamic truncation and OCRA data-input assembly are project code covered by RFC vectors.
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.
- 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: TOTP 100 steps maximum; HOTP 10,000 counters maximum.
- QR encoder: version 10 maximum.
- 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 a vault or identity provider
## Not persistent storage or an identity provider
Do not use this app as the only copy of a credential. It does not offer encrypted persistence, recovery codes, account recovery, server-side challenge state, rate limiting, audit logging, device attestation policy, FIDO certification, or phishing protection outside normal browser WebAuthn behavior.
Do not use this app as the only copy of a credential. It does not offer browser persistence, recovery codes, account recovery, server-side challenge state, rate limiting, audit logging, a preconfigured device-attestation trust policy, FIDO certification, or phishing protection outside normal browser WebAuthn behavior.
Report vulnerabilities through the repository issue tracker without including real secrets or production assertions.
+6 -3
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance
The corresponding source for OTP & Passkey Tools 0.1.0 will be 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.1.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`:
@@ -18,14 +18,17 @@ No generated bundle is the preferred source form. No runtime code, metadata or c
| Source | Revision | Use |
| -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- |
| Toolbox SDK | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a`, packages 0.2.3 | manifest, context, shell and build checks |
| Toolbox Portal | `5ed7134d42f0b563bc47a1572107ea94994dfe3c`, tag v0.10.0 | release and UX integration contract |
| Toolbox Portal | tag v0.12.0 | release and UX integration contract |
| RFC 4226 | December 2005 | HOTP semantics and vectors |
| RFC 6238 | May 2011 | TOTP semantics and vectors |
| RFC 6287 | June 2011 | OCRA suite grammar, semantics and vectors |
| RFC 6030 | October 2010 | bounded plain-secret PSKC import |
| Google Authenticator migration protobuf | observed public interchange schema | local import only; unknown fields are skipped safely |
| Aegis vault specification/importer sources | upstream `master`, accessed 19 August 2026 | bounded plaintext Aegis, andOTP, 2FAS and FreeOTP migration |
| Web Authentication | W3C WebAuthn Level 3 Recommendation, 13 January 2026 | client/authenticator structures and ceremony expectations |
| FIDO Metadata Service | v3.1 Proposed Standard, 21 May 2025 | explicit offline Metadata BLOB import and status interpretation |
| RFC 8949 / RFC 9052 / RFC 8152 registrations | published versions | bounded CBOR and COSE key interpretation |
| QR Code for JavaScript | Kazuhiko Arase, 2009 | QR construction algorithm rewritten in TypeScript under MIT terms |
| jsQR | 1.4.0 | bundled cross-browser QR image decoder under Apache-2.0 |
The OCRA implementation was authored from the normative RFC definition and tested with its published interoperability values; the RFC Java reference implementation is not included. The QR module is an acknowledged clean TypeScript rewrite of the MIT-licensed construction algorithm, with deliberately bounded version support.
+3 -1
View File
@@ -8,10 +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.
`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
+25 -5
View File
@@ -1,16 +1,18 @@
{
"name": "auth-tools",
"version": "0.1.0",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "auth-tools",
"version": "0.1.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"
},
@@ -852,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",
@@ -2662,6 +2676,12 @@
"node": ">=6"
}
},
"node_modules/jsqr": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
"license": "Apache-2.0"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3047,9 +3067,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "auth-tools",
"version": "0.1.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,9 +41,14 @@
"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"
},
"overrides": {
"nanoid": "3.3.18"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@eslint/js": "10.0.1",
+34
View File
@@ -2,6 +2,40 @@
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.
- Added DST gap rejection and earlier/later disambiguation for repeated wall-clock times, with effective local time, UTC instant, Unix seconds and TOTP counter diagnostics.
- Added an OTP timeline, bounded clock-drift search, JSON interoperability vectors, and custom RFC 6238 T0 support with provisioning-portability warnings.
- Added complete Google Authenticator multi-QR batch assembly and explicit Aegis, 2FAS, andOTP and legacy FreeOTP JSON importers.
- Added the Apache-2.0 jsQR decoder as a bundled fallback when `BarcodeDetector` is missing or cannot decode an image.
- Added collection health reports for reused secrets, duplicate labels, short secrets and non-default timing.
- Added a versioned encrypted-backup format using PBKDF2-HMAC-SHA-256 and authenticated AES-256-GCM, with no persistent browser storage.
- Added layered WebAuthn registration verification for none, packed/self, FIDO U2F, TPM 2.0, Android Key and Apple anonymous attestation statements.
- Added explicit local import and JWS verification of FIDO Metadata Service v3 snapshots, while keeping signer-chain trust separate and unestablished by default.
- Updated the transitive `nanoid` build dependency to 3.3.18; `npm audit` reports no known vulnerabilities.
## 0.1.0 - 2026-08-19
- Initial HOTP, TOTP and OCRA credential laboratory with official RFC-vector coverage.
+1 -1
View File
@@ -2,4 +2,4 @@
OTP & Passkey Tools is `GPL-3.0-or-later`; the full project licence is at `../LICENSE`.
`THIRD_PARTY_NOTICES.md` identifies shipped packages and adapted algorithms. Release preparation collates the exact installed runtime package licence files into `npm-runtime-licenses.txt`. `QRCode-MIT.txt` preserves the licence for the QR construction algorithm rewritten in this project.
`THIRD_PARTY_NOTICES.md` identifies shipped packages and adapted algorithms. Release preparation collates the exact installed runtime package licence files, including jsQR's Apache-2.0 licence, into `npm-runtime-licenses.txt`. `QRCode-MIT.txt` preserves the licence for the QR construction algorithm rewritten in this project.
+238
View File
@@ -398,6 +398,244 @@ 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
Installed from: node_modules/jsqr
==============================================================================
--- LICENSE ---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
==============================================================================
react@19.2.6
Declared licence: MIT
+21 -9
View File
@@ -4,16 +4,26 @@ A production-oriented, local-first browser workbench for OTP credentials and Web
Authentication material stays in the active tab. The application has no backend, telemetry, automatic network lookup, service worker, cookie, local-storage credential store, or IndexedDB database.
## Included in 0.1.0
## Current source capabilities
- RFC 4226 HOTP and RFC 6238 TOTP generation and bounded diagnostic verification using SHA-1, SHA-256 or SHA-512, with exact 64-bit counters and preserved leading zeroes.
- Strict `otpauth://` parsing/serialization, random secret generation, masked values, interoperability findings, live period display and a project-owned QR encoder.
- 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, Google Authenticator migration QR payloads, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Encrypted PSKC is rejected rather than guessed.
- Explicit URI-list and CSV export with an unencrypted-secret warning.
- 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, 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
@@ -25,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 when the browser implements `BarcodeDetector`; 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.
@@ -44,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.1.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
@@ -61,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 structures are decoded, but 0.1.0 does not establish attestation trust or silently download FIDO Metadata Service data. Assertion signatures are verified only with the explicitly supplied/created credential key.
- Google multi-QR batches must all be supplied; the current UI reviews one payload at a time. Google migration export and encrypted PSKC decryption are not claimed.
- CSV and URI exports contain raw OTP seeds. There is no encrypted vault format in this app.
- 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
+20 -8
View File
@@ -4,14 +4,17 @@
All inputs are processed in the browser tab. There are no application `fetch`, WebSocket, beacon, analytics, service-worker, credential-storage or remote-metadata paths. OTP secrets, OCRA PIN input, imported files, WebAuthn responses and live test credential metadata are React/session memory and disappear on reload or **Clear session**.
Downloads and clipboard writes require a user action. Exports are not encrypted. The UI masks OTP secrets and provisioning URIs by default, but masking is not memory protection.
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.
- Signature verification establishes consistency with the supplied COSE public key, not account ownership or attestation trust.
- 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,31 @@ Downloads and clipboard writes require a user action. Exports are not encrypted.
## Cryptography
HMAC, digest, random generation, public-key import and signature verification use the browser Web Cryptography API. No custom cryptographic primitive is implemented. OTP dynamic truncation and OCRA data-input assembly are project code covered by RFC vectors.
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.
- 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: TOTP 100 steps maximum; HOTP 10,000 counters maximum.
- QR encoder: version 10 maximum.
- 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 a vault or identity provider
## Not persistent storage or an identity provider
Do not use this app as the only copy of a credential. It does not offer encrypted persistence, recovery codes, account recovery, server-side challenge state, rate limiting, audit logging, device attestation policy, FIDO certification, or phishing protection outside normal browser WebAuthn behavior.
Do not use this app as the only copy of a credential. It does not offer browser persistence, recovery codes, account recovery, server-side challenge state, rate limiting, audit logging, a preconfigured device-attestation trust policy, FIDO certification, or phishing protection outside normal browser WebAuthn behavior.
Report vulnerabilities through the repository issue tracker without including real secrets or production assertions.
+6 -3
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance
The corresponding source for OTP & Passkey Tools 0.1.0 will be 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.1.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`:
@@ -18,14 +18,17 @@ No generated bundle is the preferred source form. No runtime code, metadata or c
| Source | Revision | Use |
| -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- |
| Toolbox SDK | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a`, packages 0.2.3 | manifest, context, shell and build checks |
| Toolbox Portal | `5ed7134d42f0b563bc47a1572107ea94994dfe3c`, tag v0.10.0 | release and UX integration contract |
| Toolbox Portal | tag v0.12.0 | release and UX integration contract |
| RFC 4226 | December 2005 | HOTP semantics and vectors |
| RFC 6238 | May 2011 | TOTP semantics and vectors |
| RFC 6287 | June 2011 | OCRA suite grammar, semantics and vectors |
| RFC 6030 | October 2010 | bounded plain-secret PSKC import |
| Google Authenticator migration protobuf | observed public interchange schema | local import only; unknown fields are skipped safely |
| Aegis vault specification/importer sources | upstream `master`, accessed 19 August 2026 | bounded plaintext Aegis, andOTP, 2FAS and FreeOTP migration |
| Web Authentication | W3C WebAuthn Level 3 Recommendation, 13 January 2026 | client/authenticator structures and ceremony expectations |
| FIDO Metadata Service | v3.1 Proposed Standard, 21 May 2025 | explicit offline Metadata BLOB import and status interpretation |
| RFC 8949 / RFC 9052 / RFC 8152 registrations | published versions | bounded CBOR and COSE key interpretation |
| QR Code for JavaScript | Kazuhiko Arase, 2009 | QR construction algorithm rewritten in TypeScript under MIT terms |
| jsQR | 1.4.0 | bundled cross-browser QR image decoder under Apache-2.0 |
The OCRA implementation was authored from the normative RFC definition and tested with its published interoperability values; the RFC Java reference implementation is not included. The QR module is an acknowledged clean TypeScript rewrite of the MIT-licensed construction algorithm, with deliberately bounded version support.
+3 -1
View File
@@ -8,10 +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.
`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.1.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");
+20 -6
View File
@@ -35,21 +35,30 @@ export function HelpDialog({
<p>
OTP seeds are equivalent to a second-factor credential. This app keeps
them in memory only, masks them by default and clears them on reload.
Exports are explicit and unencrypted; move them only through a secure
channel.
Plain URI/CSV exports are explicit and unencrypted. The encrypted
backup option authenticates its contents with AES-GCM and a password;
it is still not a recovery service or persistent browser vault.
</p>
<h3>OTP clock and counters</h3>
<p>
TOTP is computed from this devices clock. A rejected valid-looking
value commonly means clock drift, a different period, or the wrong
hash/digit profile. HOTP counters must remain synchronized.
value commonly means clock drift, a different T0/period, or the wrong
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
expectations, RP ID hash, authenticator flags, counter and
cryptographic signature are reported separately. Metadata is never
fetched silently.
cryptographic signature are reported separately. Attestation signature
validity, metadata status and trust are distinct results. Metadata is
imported explicitly and never fetched silently.
</p>
<h3>Live ceremonies</h3>
<p>
@@ -58,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>
);
}
File diff suppressed because it is too large Load Diff
@@ -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>
);
}
+260 -5
View File
@@ -24,6 +24,18 @@ import {
verifyAssertion,
type AssertionVerificationResult,
} from "../../webauthn/verify";
import {
verifyAttestation,
type AttestationVerificationResult,
} from "../../webauthn/attestation";
import {
findMetadataEntry,
importFidoMetadataBlob,
metadataSummary,
type FidoMetadataSnapshot,
} from "../../webauthn/metadata";
import { WebAuthnExtensionsLab } from "./WebAuthnExtensionsLab";
import { WebAuthnTrustPolicyLab } from "./WebAuthnTrustPolicyLab";
type InspectKind = "client" | "attestation" | "authenticator";
@@ -69,10 +81,18 @@ const EMPTY_ASSERTION = {
expectedRpId: typeof location === "undefined" ? "" : location.hostname,
};
const EMPTY_ATTESTATION = {
attestationObject: "",
clientDataJSON: "",
expectedChallenge: "",
expectedOrigin: typeof location === "undefined" ? "" : location.origin,
expectedRpId: typeof location === "undefined" ? "" : location.hostname,
};
export function WebAuthnWorkspace() {
const [mode, setMode] = useState<"inspect" | "verify" | "options" | "live">(
"inspect",
);
const [mode, setMode] = useState<
"inspect" | "attestation" | "verify" | "options" | "live" | "extensions"
>("inspect");
const [inspectKind, setInspectKind] = useState<InspectKind>("client");
const [inspectInput, setInspectInput] = useState("");
const [inspectOutput, setInspectOutput] = useState("");
@@ -80,6 +100,11 @@ export function WebAuthnWorkspace() {
const [assertion, setAssertion] = useState(EMPTY_ASSERTION);
const [verification, setVerification] =
useState<AssertionVerificationResult | null>(null);
const [attestation, setAttestation] = useState(EMPTY_ATTESTATION);
const [attestationResult, setAttestationResult] =
useState<AttestationVerificationResult | null>(null);
const [metadataText, setMetadataText] = useState("");
const [metadata, setMetadata] = useState<FidoMetadataSnapshot | null>(null);
const [capabilities, setCapabilities] = useState<BrowserCapabilities | null>(
null,
);
@@ -126,6 +151,37 @@ export function WebAuthnWorkspace() {
setVerification(null);
}
};
const verifyRegistration = async () => {
try {
setAttestationResult(
await verifyAttestation({
...attestation,
requireUserVerification: requireUv,
}),
);
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Attestation verification failed.",
);
setAttestationResult(null);
}
};
const importMetadata = async () => {
try {
setMetadata(await importFidoMetadataBlob(metadataText));
setError("");
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: "Metadata BLOB import failed.",
);
setMetadata(null);
}
};
const create = async () => {
setBusy(true);
setError("");
@@ -206,6 +262,13 @@ export function WebAuthnWorkspace() {
>
Inspector
</button>
<button
type="button"
className={mode === "attestation" ? "active" : ""}
onClick={() => setMode("attestation")}
>
Attestation verifier
</button>
<button
type="button"
className={mode === "verify" ? "active" : ""}
@@ -227,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">
@@ -308,6 +378,188 @@ export function WebAuthnWorkspace() {
</article>
</div>
)}
{mode === "attestation" && (
<div className="split-grid verifier-grid">
<article className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Registration response</p>
<h2>Attestation evidence</h2>
</div>
</div>
<div className="panel-body compact-form">
<label>
<span>attestationObject · Base64url CBOR</span>
<textarea
rows={6}
spellCheck={false}
value={attestation.attestationObject}
onChange={(event) =>
setAttestation((current) => ({
...current,
attestationObject: event.target.value,
}))
}
/>
</label>
<label>
<span>clientDataJSON · Base64url</span>
<textarea
rows={4}
spellCheck={false}
value={attestation.clientDataJSON}
onChange={(event) =>
setAttestation((current) => ({
...current,
clientDataJSON: event.target.value,
}))
}
/>
</label>
{(
["expectedChallenge", "expectedOrigin", "expectedRpId"] as const
).map((name) => (
<label key={name}>
<span>{name.replace("expected", "Expected ")}</span>
<input
value={attestation[name]}
onChange={(event) =>
setAttestation((current) => ({
...current,
[name]: event.target.value,
}))
}
/>
</label>
))}
<label className="check-row">
<input
type="checkbox"
checked={requireUv}
onChange={(event) => setRequireUv(event.target.checked)}
/>
<span>Require the user-verification flag</span>
</label>
<button
type="button"
className="primary-button"
onClick={() => void verifyRegistration()}
>
Verify registration layers
</button>
{attestationResult && (
<>
<dl className="capability-list compact-summary">
<div>
<dt>Format</dt>
<dd>{attestationResult.format}</dd>
</div>
<div>
<dt>Type</dt>
<dd>{attestationResult.attestationType}</dd>
</div>
<div>
<dt>AAGUID</dt>
<dd>{attestationResult.aaguid ?? "none"}</dd>
</div>
<div>
<dt>Trust</dt>
<dd>
{attestationResult.trustEstablished
? "established"
: "not established"}
</dd>
</div>
</dl>
<VerificationReport
result={attestationResult}
noun="Attestation"
/>
{metadata &&
attestationResult.aaguid &&
(() => {
const entry = findMetadataEntry(
metadata,
attestationResult.aaguid,
);
return entry ? (
<div className="warnings">
<strong>Imported metadata match</strong>
<p>
{(entry.metadataStatement?.description as string) ??
entry.aaguid}
</p>
<ul>
{entry.statusReports.map((status, index) => (
<li key={`${status.status}-${index}`}>
{status.status}
{status.effectiveDate
? ` · ${status.effectiveDate}`
: ""}
</li>
))}
</ul>
</div>
) : (
<p className="hint">
No imported metadata entry matches this AAGUID.
</p>
);
})()}
</>
)}
</div>
</article>
<article className="panel">
<div className="panel-heading">
<div>
<p className="eyebrow">Explicit offline import</p>
<h2>FIDO Metadata BLOB</h2>
</div>
</div>
<div className="panel-body compact-form">
<p className="hint">
Paste a compact MDS3 JWT snapshot. Its JWS is verified locally;
signer-chain trust remains explicitly unestablished until a
trust root policy is configured.
</p>
<textarea
rows={12}
spellCheck={false}
value={metadataText}
onChange={(event) => setMetadataText(event.target.value)}
placeholder="eyJ… .eyJ… .signature"
/>
<button
type="button"
className="secondary-button"
onClick={() => void importMetadata()}
>
Import metadata snapshot
</button>
{metadata && (
<div className="credential-card">
<strong>
{metadata.signatureVerified
? "JWS signature verified"
: "JWS signature invalid"}
</strong>
<span>{metadataSummary(metadata)}</span>
<span>Trust root: not established</span>
{metadata.warnings.map((warning) => (
<small key={warning}>{warning}</small>
))}
</div>
)}
<WebAuthnTrustPolicyLab
key={attestationResult?.aaguid ?? "no-aaguid"}
metadata={metadata}
initialAaguid={attestationResult?.aaguid ?? ""}
/>
</div>
</article>
</div>
)}
{mode === "verify" && (
<div className="split-grid verifier-grid">
<article className="panel">
@@ -560,19 +812,22 @@ export function WebAuthnWorkspace() {
</article>
</div>
)}
{mode === "extensions" && <WebAuthnExtensionsLab />}
</section>
);
}
function VerificationReport({
result,
noun = "Assertion",
}: {
result: AssertionVerificationResult;
result: Pick<AssertionVerificationResult, "verified" | "checks">;
noun?: string;
}) {
return (
<div className="verification-report" aria-live="polite">
<strong>
{result.verified ? "Assertion verified" : "Assertion rejected"}
{result.verified ? `${noun} verified` : `${noun} rejected`}
</strong>
<ul>
{result.checks.map((check) => (
+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 };
}
+213
View File
@@ -0,0 +1,213 @@
import { bytesToArrayBuffer, bytesToBase64Url } from "../crypto/encoding";
import { createHotpGenerator, totpCounter } from "./otp";
import type { OtpProfile } from "./profile";
export interface TotpTimelineEntry {
delta: number;
counter: bigint;
startsAt: number;
expiresAt: number;
code: string;
}
export interface TotpDriftMatch extends TotpTimelineEntry {
driftSeconds: number;
}
export interface CredentialHealthFinding {
severity: "info" | "warning" | "danger";
code: string;
message: string;
indexes: number[];
}
export interface CredentialHealthReport {
credentialCount: number;
findings: CredentialHealthFinding[];
}
const MAX_TIMELINE_RADIUS = 100;
const MAX_DRIFT_WINDOW = 10_000;
function validateTotp(profile: OtpProfile): void {
if (profile.kind !== "totp")
throw new Error("This diagnostic requires TOTP.");
}
function normalizedCode(code: string, digits: number): string {
const result = code.replace(/[\s-]/gu, "");
if (!new RegExp(`^[0-9]{${digits}}$`, "u").test(result))
throw new Error(`Enter a ${digits}-digit OTP code.`);
return result;
}
export async function generateTotpTimeline(
profile: OtpProfile,
timestamp: number,
past: number,
future: number,
): Promise<TotpTimelineEntry[]> {
validateTotp(profile);
if (!Number.isInteger(past) || past < 0 || past > MAX_TIMELINE_RADIUS)
throw new RangeError("Past timeline radius must be between 0 and 100.");
if (!Number.isInteger(future) || future < 0 || future > MAX_TIMELINE_RADIUS)
throw new RangeError("Future timeline radius must be between 0 and 100.");
const epoch = profile.epoch ?? 0;
const center = totpCounter(timestamp, profile.period, epoch);
const generate = await createHotpGenerator(profile);
const entries: TotpTimelineEntry[] = [];
for (let delta = -past; delta <= future; delta += 1) {
const counter = center + BigInt(delta);
if (counter < 0n) continue;
const startsAt = epoch + Number(counter) * profile.period;
entries.push({
delta,
counter,
startsAt,
expiresAt: startsAt + profile.period,
code: await generate(counter),
});
}
return entries;
}
export async function findTotpDrift(
code: string,
profile: OtpProfile,
observedAt: number,
searchWindow: number,
): Promise<TotpDriftMatch | null> {
validateTotp(profile);
const expected = normalizedCode(code, profile.digits);
if (
!Number.isInteger(searchWindow) ||
searchWindow < 0 ||
searchWindow > MAX_DRIFT_WINDOW
)
throw new RangeError(
"Drift search window must be between 0 and 10000 periods.",
);
const epoch = profile.epoch ?? 0;
const center = totpCounter(observedAt, profile.period, epoch);
const generate = await createHotpGenerator(profile);
for (let distance = 0; distance <= searchWindow; distance += 1) {
const deltas = distance === 0 ? [0] : [-distance, distance];
for (const delta of deltas) {
const counter = center + BigInt(delta);
if (counter < 0n) continue;
const candidate = await generate(counter);
if (candidate !== expected) continue;
const startsAt = epoch + Number(counter) * profile.period;
return {
delta,
counter,
startsAt,
expiresAt: startsAt + profile.period,
code: candidate,
driftSeconds: delta * profile.period,
};
}
}
return null;
}
async function fingerprint(secret: Uint8Array): Promise<string> {
return bytesToBase64Url(
new Uint8Array(
await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(secret)),
),
);
}
export async function credentialHealthReport(
profiles: readonly OtpProfile[],
): Promise<CredentialHealthReport> {
if (profiles.length > 10_000)
throw new RangeError("Health reports are limited to 10000 credentials.");
const findings: CredentialHealthFinding[] = [];
const secrets = new Map<string, number[]>();
const labels = new Map<string, number[]>();
for (const [index, profile] of profiles.entries()) {
const bits = profile.secret.byteLength * 8;
if (bits < 128)
findings.push({
severity: "danger",
code: "short-secret",
message: `${profile.issuer || "Credential"} / ${profile.account} has only ${bits} secret bits.`,
indexes: [index],
});
else if (bits < 160)
findings.push({
severity: "warning",
code: "limited-secret",
message: `${profile.issuer || "Credential"} / ${profile.account} is below the 160-bit recommendation.`,
indexes: [index],
});
if (profile.algorithm === "SHA-1")
findings.push({
severity: "info",
code: "sha1",
message: `${profile.issuer || "Credential"} / ${profile.account} uses the widely compatible SHA-1 OTP profile.`,
indexes: [index],
});
if (
profile.kind === "totp" &&
(profile.period !== 30 || (profile.epoch ?? 0) !== 0)
)
findings.push({
severity: "warning",
code: "nonstandard-time",
message: `${profile.issuer || "Credential"} / ${profile.account} uses non-default TOTP timing.`,
indexes: [index],
});
const secretKey = await fingerprint(profile.secret);
secrets.set(secretKey, [...(secrets.get(secretKey) ?? []), index]);
const labelKey = `${profile.issuer.trim().toLocaleLowerCase()}\u0000${profile.account.trim().toLocaleLowerCase()}`;
labels.set(labelKey, [...(labels.get(labelKey) ?? []), index]);
}
for (const indexes of secrets.values())
if (indexes.length > 1)
findings.unshift({
severity: "danger",
code: "reused-secret",
message: `The same secret is reused by ${indexes.length} credentials.`,
indexes,
});
for (const indexes of labels.values())
if (indexes.length > 1)
findings.push({
severity: "warning",
code: "duplicate-label",
message: `${indexes.length} credentials share the same issuer and account label.`,
indexes,
});
return { credentialCount: profiles.length, findings };
}
export function exportInteroperabilityVectors(
profile: OtpProfile,
timeline: readonly TotpTimelineEntry[],
): string {
return `${JSON.stringify(
{
schema: "de.add-ideas.auth-tools.otp-vectors",
version: 1,
generatedAt: new Date().toISOString(),
profile: {
kind: profile.kind,
issuer: profile.issuer,
account: profile.account,
algorithm: profile.algorithm,
digits: profile.digits,
period: profile.period,
t0: profile.epoch ?? 0,
},
vectors: timeline.map((entry) => ({
...entry,
counter: entry.counter.toString(),
})),
},
null,
2,
)}\n`;
}
+246
View File
@@ -0,0 +1,246 @@
import {
base32ToBytes,
base64UrlToBytes,
bytesToArrayBuffer,
bytesToBase32,
bytesToBase64Url,
bytesToUtf8,
randomBytes,
utf8ToBytes,
} from "../crypto/encoding";
import type { OtpHashAlgorithm } from "./otp";
import type { OtpProfile } from "./profile";
const SCHEMA = "de.add-ideas.auth-tools.encrypted-otp-backup";
const ITERATIONS = 600_000;
const MAX_BACKUP_BYTES = 16 * 1024 * 1024;
interface BackupEnvelope {
schema: typeof SCHEMA;
version: 1;
kdf: { name: "PBKDF2"; hash: "SHA-256"; iterations: number; salt: string };
cipher: { name: "AES-GCM"; iv: string };
ciphertext: string;
}
function stableHeader(
envelope: Omit<BackupEnvelope, "ciphertext">,
): Uint8Array {
return utf8ToBytes(JSON.stringify(envelope));
}
async function deriveKey(
password: string,
salt: Uint8Array,
iterations: number,
): Promise<CryptoKey> {
if (password.length < 12)
throw new Error("Use a backup password of at least 12 characters.");
const material = await crypto.subtle.importKey(
"raw",
bytesToArrayBuffer(utf8ToBytes(password)),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
hash: "SHA-256",
salt: bytesToArrayBuffer(salt),
iterations,
},
material,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
function serializable(profiles: readonly OtpProfile[]): unknown {
if (profiles.length > 10_000)
throw new Error("Encrypted backups are limited to 10000 credentials.");
return profiles.map((profile) => ({
kind: profile.kind,
secret: bytesToBase32(profile.secret),
issuer: profile.issuer,
account: profile.account,
algorithm: profile.algorithm,
digits: profile.digits,
period: profile.period,
epoch: profile.epoch ?? 0,
counter: profile.counter.toString(),
extensions: [...profile.extensions],
}));
}
export async function encryptOtpBackup(
profiles: readonly OtpProfile[],
password: string,
): Promise<string> {
const salt = randomBytes(16);
const iv = randomBytes(16).slice(0, 12);
const header: Omit<BackupEnvelope, "ciphertext"> = {
schema: SCHEMA,
version: 1,
kdf: {
name: "PBKDF2",
hash: "SHA-256",
iterations: ITERATIONS,
salt: bytesToBase64Url(salt),
},
cipher: { name: "AES-GCM", iv: bytesToBase64Url(iv) },
};
const plaintext = utf8ToBytes(JSON.stringify(serializable(profiles)));
if (plaintext.byteLength > MAX_BACKUP_BYTES)
throw new Error("Backup plaintext exceeds the 16 MiB safety limit.");
const key = await deriveKey(password, salt, ITERATIONS);
const ciphertext = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: bytesToArrayBuffer(iv),
additionalData: bytesToArrayBuffer(stableHeader(header)),
tagLength: 128,
},
key,
bytesToArrayBuffer(plaintext),
);
return `${JSON.stringify({ ...header, ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)) }, null, 2)}\n`;
}
function record(value: unknown, name: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error(`${name} is invalid.`);
return value as Record<string, unknown>;
}
export async function decryptOtpBackup(
input: string,
password: string,
): Promise<OtpProfile[]> {
if (new TextEncoder().encode(input).byteLength > MAX_BACKUP_BYTES * 2)
throw new Error("Encrypted backup exceeds the safety limit.");
let parsed: unknown;
try {
parsed = JSON.parse(input) as unknown;
} catch {
throw new Error("Encrypted backup is not valid JSON.");
}
const envelope = record(parsed, "Encrypted backup");
if (envelope.schema !== SCHEMA || envelope.version !== 1)
throw new Error("Unsupported encrypted backup schema or version.");
const kdf = record(envelope.kdf, "KDF parameters");
const cipher = record(envelope.cipher, "Cipher parameters");
if (
kdf.name !== "PBKDF2" ||
kdf.hash !== "SHA-256" ||
cipher.name !== "AES-GCM"
)
throw new Error("Unsupported encrypted backup algorithm.");
const iterations = Number(kdf.iterations);
if (
!Number.isInteger(iterations) ||
iterations < 100_000 ||
iterations > 2_000_000
)
throw new Error("Backup KDF work factor is outside the supported range.");
const salt = base64UrlToBytes(String(kdf.salt));
const iv = base64UrlToBytes(String(cipher.iv));
const ciphertext = base64UrlToBytes(String(envelope.ciphertext));
if (
salt.length < 16 ||
salt.length > 64 ||
iv.length !== 12 ||
ciphertext.length > MAX_BACKUP_BYTES + 16
)
throw new Error("Encrypted backup parameters have invalid lengths.");
const header: Omit<BackupEnvelope, "ciphertext"> = {
schema: SCHEMA,
version: 1,
kdf: {
name: "PBKDF2",
hash: "SHA-256",
iterations,
salt: String(kdf.salt),
},
cipher: { name: "AES-GCM", iv: String(cipher.iv) },
};
try {
const key = await deriveKey(password, salt, iterations);
const plaintext = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: bytesToArrayBuffer(iv),
additionalData: bytesToArrayBuffer(stableHeader(header)),
tagLength: 128,
},
key,
bytesToArrayBuffer(ciphertext),
);
const entries = JSON.parse(
bytesToUtf8(new Uint8Array(plaintext)),
) as unknown;
if (!Array.isArray(entries) || entries.length > 10_000)
throw new Error("Backup payload has an invalid credential list.");
return entries.map((raw, index) => {
const item = record(raw, `Credential ${index + 1}`);
const kind =
item.kind === "hotp" ? "hotp" : item.kind === "totp" ? "totp" : null;
const algorithm = (["SHA-1", "SHA-256", "SHA-512"] as const).find(
(value) => value === item.algorithm,
);
const digits = Number(item.digits);
const period = Number(item.period);
const epoch = Number(item.epoch ?? 0);
if (
!kind ||
!algorithm ||
!Number.isInteger(digits) ||
digits < 6 ||
digits > 10 ||
!Number.isInteger(period) ||
period < 1 ||
period > 86_400 ||
!Number.isSafeInteger(epoch) ||
epoch < 0 ||
!/^\d+$/u.test(String(item.counter))
)
throw new Error(`Credential ${index + 1} has invalid fields.`);
const extensions = Array.isArray(item.extensions)
? new Map(
item.extensions.map((pair) => {
if (
!Array.isArray(pair) ||
pair.length !== 2 ||
typeof pair[0] !== "string" ||
typeof pair[1] !== "string"
)
throw new Error(
`Credential ${index + 1} has invalid extensions.`,
);
return [pair[0], pair[1]] as const;
}),
)
: new Map<string, string>();
return {
kind,
secret: base32ToBytes(String(item.secret), { allowPadding: true }),
issuer: String(item.issuer ?? ""),
account: String(item.account ?? "Imported account"),
algorithm: algorithm as OtpHashAlgorithm,
digits,
period,
epoch,
counter: BigInt(String(item.counter)),
extensions,
};
});
} catch (error) {
if (error instanceof Error && error.message.startsWith("Credential "))
throw error;
throw new Error(
"Backup authentication failed. The password or file is incorrect.",
{ cause: error },
);
}
}
+208 -6
View File
@@ -7,6 +7,20 @@ export interface ImportResult {
warnings: string[];
}
export interface GoogleMigrationExport {
uris: string[];
warnings: string[];
}
interface GoogleMigrationPart {
profiles: OtpProfile[];
warnings: string[];
version: bigint;
batchSize: number;
batchIndex: number;
batchId: bigint;
}
const MAX_IMPORT_BYTES = 4 * 1024 * 1024;
function utf8(bytes: Uint8Array): string {
@@ -29,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 };
@@ -144,7 +202,7 @@ function decodeStandardBase64(input: string): Uint8Array {
}
}
export function importGoogleMigration(input: string): ImportResult {
function parseGoogleMigrationPart(input: string): GoogleMigrationPart {
if (input.length > MAX_IMPORT_BYTES * 2)
throw new Error("Migration URI is too large.");
const url = new URL(input);
@@ -162,11 +220,15 @@ export function importGoogleMigration(input: string): ImportResult {
warnings.push(
`Migration payload version ${version.toString()} is not documented by this importer.`,
);
const batchSize = firstInt(fields, 3, 1n);
if (batchSize > 1n)
warnings.push(
`This is one part of a ${batchSize.toString()}-QR migration batch; import all parts.`,
const batchSizeValue = firstInt(fields, 3, 1n);
const batchIndexValue = firstInt(fields, 4, 0n);
const batchId = firstInt(fields, 5, 0n);
if (batchSizeValue < 1n || batchSizeValue > 100n)
throw new Error(
"Google migration batch size is outside the supported range.",
);
if (batchIndexValue < 0n || batchIndexValue >= batchSizeValue)
throw new Error("Google migration batch index is invalid.");
const parameters = fields.get(1) ?? [];
const profiles = parameters.map((value) => {
if (!(value instanceof Uint8Array))
@@ -175,7 +237,147 @@ export function importGoogleMigration(input: string): ImportResult {
});
if (!profiles.length)
throw new Error("Migration payload contains no OTP credentials.");
return { profiles, warnings };
return {
profiles,
warnings,
version,
batchSize: Number(batchSizeValue),
batchIndex: Number(batchIndexValue),
batchId,
};
}
export function importGoogleMigration(input: string): ImportResult {
const part = parseGoogleMigrationPart(input);
if (part.batchSize > 1)
part.warnings.push(
`This is part ${part.batchIndex + 1} of ${part.batchSize}; paste every migration URI to assemble the batch.`,
);
return { profiles: part.profiles, warnings: part.warnings };
}
export function importGoogleMigrationBatch(
inputs: readonly string[],
): ImportResult {
if (inputs.length === 0 || inputs.length > 100)
throw new Error("Provide between 1 and 100 Google migration parts.");
const parts = inputs.map(parseGoogleMigrationPart);
const first = parts[0]!;
for (const part of parts) {
if (
part.batchId !== first.batchId ||
part.batchSize !== first.batchSize ||
part.version !== first.version
)
throw new Error("Google migration parts belong to different batches.");
}
const byIndex = new Map<number, GoogleMigrationPart>();
for (const part of parts) {
if (byIndex.has(part.batchIndex))
throw new Error(
`Google migration part ${part.batchIndex + 1} is duplicated.`,
);
byIndex.set(part.batchIndex, part);
}
if (byIndex.size !== first.batchSize) {
const missing = Array.from(
{ length: first.batchSize },
(_, index) => index + 1,
).filter((index) => !byIndex.has(index - 1));
throw new Error(
`Google migration batch is incomplete; missing part${missing.length === 1 ? "" : "s"} ${missing.join(", ")}.`,
);
}
const ordered = [...byIndex.values()].sort(
(left, right) => left.batchIndex - right.batchIndex,
);
return {
profiles: ordered.flatMap((part) => part.profiles),
warnings: [...new Set(ordered.flatMap((part) => part.warnings))],
};
}
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[][] {
+88 -18
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 {
@@ -55,7 +62,9 @@ function counterBytes(counter: bigint): Uint8Array {
return output;
}
export async function hotp(options: HotpOptions): Promise<string> {
export async function createHotpGenerator(
options: Omit<HotpOptions, "counter">,
): Promise<(counter: bigint) => Promise<string>> {
validateSecret(options.secret);
const digits = options.digits ?? 6;
const algorithm = options.algorithm ?? "SHA-1";
@@ -67,21 +76,29 @@ export async function hotp(options: HotpOptions): Promise<string> {
false,
["sign"],
);
const digest = new Uint8Array(
await crypto.subtle.sign(
"HMAC",
key,
bytesToArrayBuffer(counterBytes(options.counter)),
),
);
const offset = (digest.at(-1) ?? 0) & 0x0f;
if (offset + 3 >= digest.length) throw new Error("HMAC result is too short.");
const binary =
((digest[offset]! & 0x7f) << 24) |
((digest[offset + 1]! & 0xff) << 16) |
((digest[offset + 2]! & 0xff) << 8) |
(digest[offset + 3]! & 0xff);
return (binary % 10 ** digits).toString().padStart(digits, "0");
return async (counter: bigint): Promise<string> => {
const digest = new Uint8Array(
await crypto.subtle.sign(
"HMAC",
key,
bytesToArrayBuffer(counterBytes(counter)),
),
);
const offset = (digest.at(-1) ?? 0) & 0x0f;
if (offset + 3 >= digest.length)
throw new Error("HMAC result is too short.");
const binary =
((digest[offset]! & 0x7f) << 24) |
((digest[offset + 1]! & 0xff) << 16) |
((digest[offset + 2]! & 0xff) << 8) |
(digest[offset + 3]! & 0xff);
return (binary % 10 ** digits).toString().padStart(digits, "0");
};
}
export async function hotp(options: HotpOptions): Promise<string> {
const generate = await createHotpGenerator(options);
return generate(options.counter);
}
export function totpCounter(timestamp: number, period = 30, epoch = 0): bigint {
@@ -131,12 +148,13 @@ export async function verifyTotp(
options.period,
options.epoch,
);
const generate = await createHotpGenerator(options);
for (let distance = 0; distance <= window; distance += 1) {
const deltas = distance === 0 ? [0] : [-distance, distance];
for (const delta of deltas) {
const counter = baseCounter + BigInt(delta);
if (counter < 0n) continue;
const candidate = await hotp({ ...options, counter, digits });
const candidate = await generate(counter);
if (candidate === normalized) return { code: candidate, counter, delta };
}
}
@@ -155,11 +173,63 @@ export async function verifyHotp(
if (!Number.isInteger(lookAhead) || lookAhead < 0 || lookAhead > 10_000) {
throw new RangeError("HOTP look-ahead must be between 0 and 10000.");
}
const generate = await createHotpGenerator(options);
for (let delta = 0; delta <= lookAhead; delta += 1) {
const counter = options.counter + BigInt(delta);
if (counter > MAX_HOTP_COUNTER) break;
const candidate = await hotp({ ...options, counter, digits });
const candidate = await generate(counter);
if (candidate === normalized) return { code: candidate, counter, delta };
}
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;
}
+23
View File
@@ -9,6 +9,8 @@ export interface OtpProfile {
algorithm: OtpHashAlgorithm;
digits: number;
period: number;
/** Unix seconds used as RFC 6238 T0. Non-zero values are not portable. */
epoch?: number;
counter: bigint;
extensions: ReadonlyMap<string, string>;
}
@@ -19,6 +21,7 @@ export interface OtpProfileWarning {
| "short-secret"
| "nonstandard-digits"
| "nonstandard-period"
| "nonstandard-epoch"
| "extension";
message: string;
}
@@ -126,6 +129,10 @@ export function parseOtpAuth(input: string): ParsedOtpAuth {
if (!Number.isInteger(period) || period < 1 || period > 86_400) {
throw new Error("TOTP period is outside the supported range.");
}
const epoch = integerParameter(parameters.get("t0"), "0", "TOTP T0");
if (!Number.isSafeInteger(epoch) || epoch < 0) {
throw new Error("TOTP T0 is outside the supported range.");
}
const counterText = parameters.get("counter");
if (kind === "hotp" && counterText === undefined) {
throw new Error("HOTP provisioning requires a counter.");
@@ -144,6 +151,7 @@ export function parseOtpAuth(input: string): ParsedOtpAuth {
"digits",
"period",
"counter",
"t0",
]);
const extensions = new Map(
[...parameters].filter(([name]) => !known.has(name)),
@@ -178,6 +186,13 @@ export function parseOtpAuth(input: string): ParsedOtpAuth {
message: "Some authenticator applications ignore non-default periods.",
});
}
if (kind === "totp" && epoch !== 0) {
warnings.push({
code: "nonstandard-epoch",
message:
"A non-zero T0 is valid RFC 6238 input but is not standardized by the otpauth URI format.",
});
}
for (const name of extensions.keys()) {
warnings.push({
code: "extension",
@@ -193,6 +208,7 @@ export function parseOtpAuth(input: string): ParsedOtpAuth {
algorithm,
digits,
period,
epoch,
counter,
extensions,
},
@@ -226,6 +242,8 @@ export function serializeOtpAuth(profile: OtpProfile): string {
if (profile.kind === "totp")
parameters.set("period", profile.period.toString());
else parameters.set("counter", profile.counter.toString());
if (profile.kind === "totp" && (profile.epoch ?? 0) !== 0)
parameters.set("t0", String(profile.epoch));
for (const [name, value] of profile.extensions) {
if (!parameters.has(name)) parameters.set(name, value);
}
@@ -256,6 +274,11 @@ export function profileStrength(profile: OtpProfile): {
if (profile.kind === "totp" && profile.period !== 30) {
findings.push("Non-default periods are frequently ignored by importers.");
}
if (profile.kind === "totp" && (profile.epoch ?? 0) !== 0) {
findings.push(
"Non-zero T0 is an RFC 6238 diagnostic parameter but most provisioning clients ignore it.",
);
}
if (findings.length === 0)
findings.push("No structural interoperability issue detected.");
return { secretBits, findings };
+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);
}
}
+205
View File
@@ -0,0 +1,205 @@
export const MAX_TOTP_PERIOD_OFFSET = 10_000_000;
export type TimeDisambiguation = "earlier" | "later";
export interface LocalDateTimeFields {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
millisecond: number;
}
const formatterCache = new Map<string, Intl.DateTimeFormat>();
function formatter(timeZone: string): Intl.DateTimeFormat {
const normalized = timeZone.trim();
if (!normalized || normalized.length > 100)
throw new RangeError("Enter a valid IANA timezone.");
const existing = formatterCache.get(normalized);
if (existing) return existing;
let created: Intl.DateTimeFormat;
try {
created = new Intl.DateTimeFormat("en-GB-u-ca-gregory-nu-latn", {
timeZone: normalized,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hourCycle: "h23",
});
} catch {
throw new RangeError(`Unknown IANA timezone: ${normalized}`);
}
formatterCache.set(normalized, created);
return created;
}
function utcTimestamp(fields: LocalDateTimeFields): number {
const date = new Date(0);
date.setUTCFullYear(fields.year, fields.month - 1, fields.day);
date.setUTCHours(
fields.hour,
fields.minute,
fields.second,
fields.millisecond,
);
return date.getTime();
}
function fieldsAt(timestampMs: number, timeZone: string): LocalDateTimeFields {
const values = new Map(
formatter(timeZone)
.formatToParts(new Date(timestampMs))
.map((part) => [part.type, part.value]),
);
return {
year: Number(values.get("year")),
month: Number(values.get("month")),
day: Number(values.get("day")),
hour: Number(values.get("hour")),
minute: Number(values.get("minute")),
second: Number(values.get("second")),
millisecond: Number(values.get("fractionalSecond") ?? "0"),
};
}
function sameFields(
left: LocalDateTimeFields,
right: LocalDateTimeFields,
): boolean {
return (
left.year === right.year &&
left.month === right.month &&
left.day === right.day &&
left.hour === right.hour &&
left.minute === right.minute &&
left.second === right.second &&
left.millisecond === right.millisecond
);
}
export function parseLocalDateTime(value: string): LocalDateTimeFields {
const match =
/^(?<year>[0-9]{4})-(?<month>[0-9]{2})-(?<day>[0-9]{2})T(?<hour>[0-9]{2}):(?<minute>[0-9]{2})(?::(?<second>[0-9]{2})(?:\.(?<fraction>[0-9]{1,3}))?)?$/u.exec(
value,
);
if (!match?.groups)
throw new RangeError("Enter a complete local date and time.");
const fields = {
year: Number(match.groups.year),
month: Number(match.groups.month),
day: Number(match.groups.day),
hour: Number(match.groups.hour),
minute: Number(match.groups.minute),
second: Number(match.groups.second ?? "0"),
millisecond: Number((match.groups.fraction ?? "0").padEnd(3, "0")),
};
if (fields.year < 1970 || fields.year > 9999)
throw new RangeError("TOTP time must be between 1970 and 9999.");
const timestamp = utcTimestamp(fields);
if (
!Number.isFinite(timestamp) ||
!sameFields(fieldsAt(timestamp, "UTC"), fields)
)
throw new RangeError("Enter a valid calendar date and time.");
return fields;
}
export function zonedDateTimeCandidates(
value: string,
timeZone: string,
): number[] {
const desired = parseLocalDateTime(value);
const wallTimestamp = utcTimestamp(desired);
const offsets = new Set<number>();
for (let hours = -48; hours <= 48; hours += 6) {
const sample = wallTimestamp + hours * 60 * 60 * 1000;
const roundedSample = Math.floor(sample / 1000) * 1000;
offsets.add(
utcTimestamp(fieldsAt(roundedSample, timeZone)) - roundedSample,
);
}
const candidates = [...offsets]
.map((offset) => wallTimestamp - offset)
.filter(
(candidate) =>
candidate >= 0 && sameFields(fieldsAt(candidate, timeZone), desired),
);
return [...new Set(candidates)].sort((left, right) => left - right);
}
export function resolveZonedDateTime(
value: string,
timeZone: string,
disambiguation: TimeDisambiguation = "earlier",
): { timestampMs: number; candidates: number[] } {
const candidates = zonedDateTimeCandidates(value, timeZone);
if (!candidates.length) {
throw new RangeError(
"That local time does not exist in this timezone because of a clock change.",
);
}
return {
timestampMs:
disambiguation === "later" ? candidates.at(-1)! : candidates.at(0)!,
candidates,
};
}
function pad(value: number, length = 2): string {
return value.toString().padStart(length, "0");
}
export function formatDateTimeLocal(
timestampMs: number,
timeZone: string,
): string {
if (!Number.isFinite(timestampMs) || timestampMs < 0)
throw new RangeError("TOTP timestamp must be non-negative.");
const fields = fieldsAt(timestampMs, timeZone);
return `${pad(fields.year, 4)}-${pad(fields.month)}-${pad(fields.day)}T${pad(fields.hour)}:${pad(fields.minute)}:${pad(fields.second)}`;
}
export function shiftTimestampByPeriods(
timestampMs: number,
periodSeconds: number,
periodOffset: number,
): number {
if (!Number.isFinite(timestampMs) || timestampMs < 0)
throw new RangeError("TOTP timestamp must be non-negative.");
if (
!Number.isInteger(periodSeconds) ||
periodSeconds < 1 ||
periodSeconds > 86_400
)
throw new RangeError("TOTP period must be between 1 and 86400 seconds.");
if (
!Number.isSafeInteger(periodOffset) ||
Math.abs(periodOffset) > MAX_TOTP_PERIOD_OFFSET
)
throw new RangeError(
`Period offset must be between -${MAX_TOTP_PERIOD_OFFSET} and ${MAX_TOTP_PERIOD_OFFSET}.`,
);
const shifted = timestampMs + periodOffset * periodSeconds * 1000;
if (!Number.isSafeInteger(shifted) || shifted < 0)
throw new RangeError(
"The selected period offset is outside supported time.",
);
return shifted;
}
export function supportedTimeZones(): string[] {
const implementation = Intl as typeof Intl & {
supportedValuesOf?: (key: "timeZone") => string[];
};
const zones = implementation.supportedValuesOf?.("timeZone") ?? [];
return [...new Set(["UTC", ...zones])].sort((left, right) =>
left.localeCompare(right),
);
}
+404
View File
@@ -0,0 +1,404 @@
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";
const MAX_BACKUP_BYTES = 16 * 1024 * 1024;
const MAX_ENTRIES = 10_000;
type JsonObject = Record<string, unknown>;
function object(value: unknown, context: string): JsonObject {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error(`${context} must be a JSON object.`);
return value as JsonObject;
}
function text(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function integer(value: unknown, fallback: number, name: string): number {
const parsed = typeof value === "number" ? value : Number(value ?? fallback);
if (!Number.isSafeInteger(parsed) || parsed < 0)
throw new Error(`${name} must be a non-negative integer.`);
return parsed;
}
function algorithm(value: unknown): OtpHashAlgorithm {
const normalized = text(value, "SHA1").toUpperCase().replaceAll("-", "");
if (normalized === "SHA1") return "SHA-1";
if (normalized === "SHA256") return "SHA-256";
if (normalized === "SHA512") return "SHA-512";
throw new Error(`Unsupported OTP algorithm ${normalized}.`);
}
function kind(value: unknown): "totp" | "hotp" {
const normalized = text(value, "totp").toLowerCase();
if (normalized === "totp" || normalized === "hotp") return normalized;
throw new Error(`Unsupported OTP type ${normalized}.`);
}
function secret(value: unknown): Uint8Array {
if (Array.isArray(value)) {
if (value.length === 0 || value.length > 1024)
throw new Error("OTP secret byte array has an invalid length.");
return Uint8Array.from(
value.map((item) => {
if (
!Number.isInteger(item) ||
Number(item) < -128 ||
Number(item) > 255
)
throw new Error("OTP secret byte array contains an invalid byte.");
return Number(item) & 0xff;
}),
);
}
const decoded = base32ToBytes(text(value), {
allowPadding: true,
allowSeparators: true,
});
if (!decoded.length || decoded.length > 1024)
throw new Error("OTP secret has an invalid length.");
return decoded;
}
function profile(input: {
secret: unknown;
issuer?: unknown;
account?: unknown;
type?: unknown;
algorithm?: unknown;
digits?: unknown;
period?: unknown;
counter?: unknown;
}): OtpProfile {
const otpKind = kind(input.type);
const digits = integer(input.digits, 6, "OTP digits");
const period = integer(input.period, 30, "TOTP period");
const counter = BigInt(integer(input.counter, 0, "HOTP counter"));
if (digits < 6 || digits > 10)
throw new Error("OTP digits must be between 6 and 10.");
if (period < 1 || period > 86_400)
throw new Error("TOTP period is outside the supported range.");
return {
kind: otpKind,
secret: secret(input.secret),
issuer: text(input.issuer),
account: text(input.account, "Imported account"),
algorithm: algorithm(input.algorithm),
digits,
period,
counter,
extensions: new Map(),
};
}
function parseJson(input: string): unknown {
if (new TextEncoder().encode(input).byteLength > MAX_BACKUP_BYTES)
throw new Error("Backup exceeds the 16 MiB safety limit.");
try {
return JSON.parse(input) as unknown;
} catch {
throw new Error("Backup is not valid JSON.");
}
}
function finish(profiles: OtpProfile[], warnings: string[]): ImportResult {
if (profiles.length === 0)
throw new Error("Backup contains no supported OTP credentials.");
if (profiles.length > MAX_ENTRIES)
throw new Error("Backup contains more than 10000 credentials.");
return { profiles, warnings };
}
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.");
const warnings: string[] = [];
const profiles: OtpProfile[] = [];
for (const [index, raw] of entries.entries()) {
const entry = object(raw, `Aegis entry ${index + 1}`);
const type = text(entry.type, "totp").toLowerCase();
if (type !== "totp" && type !== "hotp") {
warnings.push(
`Aegis entry ${index + 1}: ${type} tokens are not portable HOTP/TOTP and were skipped.`,
);
continue;
}
const info = object(entry.info, `Aegis entry ${index + 1} info`);
profiles.push(
profile({
secret: info.secret,
issuer: entry.issuer,
account: entry.name,
type,
algorithm: info.algo,
digits: info.digits,
period: info.period,
counter: info.counter,
}),
);
}
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))
throw new Error("andOTP plain backups must be a JSON array.");
const profiles = parsed.map((raw, index) => {
const entry = object(raw, `andOTP entry ${index + 1}`);
return profile({
secret: entry.secret,
issuer: entry.issuer,
account: entry.label ?? entry.account,
type: entry.type,
algorithm: entry.algorithm,
digits: entry.digits,
period: entry.period,
counter: entry.counter,
});
});
return finish(profiles, []);
}
export function importTwoFasBackup(input: string): ImportResult {
const root = object(parseJson(input), "2FAS backup");
const services = root.services;
if (!Array.isArray(services))
throw new Error("2FAS backup has no services array.");
const warnings: string[] = [];
const profiles = services.map((raw, index) => {
const service = object(raw, `2FAS service ${index + 1}`);
const otp = object(
service.otp ?? {},
`2FAS service ${index + 1} OTP settings`,
);
const tokenType = text(otp.tokenType ?? service.type, "totp").toLowerCase();
if (tokenType !== "totp" && tokenType !== "hotp")
warnings.push(
`2FAS service ${index + 1}: unknown token type ${tokenType} was interpreted as TOTP.`,
);
return profile({
secret: service.secret,
issuer: otp.issuer ?? service.name,
account: otp.account ?? otp.label ?? service.name,
type: tokenType === "hotp" ? "hotp" : "totp",
algorithm: otp.algorithm,
digits: otp.digits,
period: otp.period,
counter: otp.counter,
});
});
return finish(profiles, warnings);
}
export function importFreeOtpBackup(input: string): ImportResult {
const parsed = parseJson(input);
const root = Array.isArray(parsed)
? parsed
: object(parsed, "FreeOTP backup").tokens;
if (!Array.isArray(root))
throw new Error("FreeOTP backup has no token array.");
const profiles = root.map((raw, index) => {
const token =
typeof raw === "string"
? object(JSON.parse(raw) as unknown, `FreeOTP token ${index + 1}`)
: object(raw, `FreeOTP token ${index + 1}`);
return profile({
secret: token.secret,
issuer: token.issuerExt ?? token.issuer,
account: token.label ?? token.account,
type: token.type,
algorithm: token.algo ?? token.algorithm,
digits: token.digits,
period: token.period,
counter: token.counter,
});
});
return finish(profiles, [
"FreeOTP 2.x password-encrypted XML backups must first be decrypted by FreeOTP; legacy JSON token exports are supported directly.",
]);
}
export type VendorBackupFormat = "aegis" | "andotp" | "2fas" | "freeotp";
export function importVendorBackup(
format: VendorBackupFormat,
input: string,
): ImportResult {
switch (format) {
case "aegis":
return importAegisBackup(input);
case "andotp":
return importAndOtpBackup(input);
case "2fas":
return importTwoFasBackup(input);
case "freeotp":
return importFreeOtpBackup(input);
}
}
+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();
}
}
+70
View File
@@ -0,0 +1,70 @@
import jsQR from "jsqr";
interface BarcodeResult {
rawValue: string;
}
interface BarcodeDetectorLike {
detect(source: ImageBitmap): Promise<BarcodeResult[]>;
}
interface BarcodeDetectorConstructor {
new (options: { formats: string[] }): BarcodeDetectorLike;
}
const MAX_FILE_SIZE = 16 * 1024 * 1024;
const MAX_DIMENSION = 4096;
const MAX_PIXELS = 16_777_216;
export async function decodeQrImage(file: File): Promise<string> {
if (file.size > MAX_FILE_SIZE)
throw new Error("QR image is larger than the 16 MiB safety limit.");
const bitmap = await createImageBitmap(file);
try {
if (
bitmap.width < 1 ||
bitmap.height < 1 ||
bitmap.width > MAX_DIMENSION ||
bitmap.height > MAX_DIMENSION ||
bitmap.width * bitmap.height > MAX_PIXELS
)
throw new Error(
"QR image dimensions exceed the 4096 px / 16-megapixel safety limit.",
);
const Constructor = (
globalThis as typeof globalThis & {
BarcodeDetector?: BarcodeDetectorConstructor;
}
).BarcodeDetector;
if (Constructor) {
try {
const result = await new Constructor({ formats: ["qr_code"] }).detect(
bitmap,
);
if (result[0]?.rawValue) return result[0].rawValue;
} catch {
// The bundled decoder below is the interoperable fallback.
}
}
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d", {
alpha: false,
willReadFrequently: true,
});
if (!context)
throw new Error("This browser cannot create a 2D decoder surface.");
context.drawImage(bitmap, 0, 0);
const pixels = context.getImageData(0, 0, bitmap.width, bitmap.height);
const decoded = jsQR(pixels.data, pixels.width, pixels.height, {
inversionAttempts: "attemptBoth",
});
if (!decoded?.data) throw new Error("No QR code was found in the image.");
return decoded.data;
} finally {
bitmap.close();
}
}
+244 -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;
@@ -386,6 +389,81 @@ button:disabled {
background: var(--auth-blue);
transition: width 0.4s linear;
}
.time-travel {
display: grid;
gap: 0.75rem;
margin: 0 1rem 1rem;
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.75);
background: var(--toolbox-surface-soft);
}
.time-mode {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.25rem;
padding: 0.2rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.6);
background: var(--toolbox-background);
}
.time-mode button {
min-width: 0;
border: 0;
border-radius: calc(var(--toolbox-radius) * 0.45);
padding: 0.45rem 0.35rem;
background: transparent;
color: var(--toolbox-muted);
font-size: 0.72rem;
font-weight: 750;
}
.time-mode button.active {
background: var(--toolbox-surface);
color: var(--toolbox-text);
box-shadow: 0 1px 5px rgb(20 30 60 / 10%);
}
.time-offset-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 0.45rem;
align-items: end;
}
.absolute-time-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.6rem;
align-items: end;
}
.time-readout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.45rem;
margin: 0;
}
.time-readout div {
min-width: 0;
padding: 0.5rem;
border: 1px solid var(--toolbox-border);
border-radius: calc(var(--toolbox-radius) * 0.5);
background: var(--toolbox-surface);
}
.time-readout dt {
color: var(--toolbox-muted);
font-size: 0.65rem;
font-weight: 750;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.time-readout dd {
margin: 0.18rem 0 0;
overflow-wrap: anywhere;
color: var(--toolbox-text);
font:
650 0.72rem/1.35 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.verify-row {
padding: 0 1rem;
}
@@ -676,6 +754,155 @@ button:disabled {
color: var(--toolbox-muted);
}
.timeline-list {
display: grid;
max-height: 28rem;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
}
.timeline-list > div {
display: grid;
grid-template-columns: 3rem 7rem 1fr;
gap: 0.7rem;
align-items: center;
padding: 0.55rem 0.7rem;
border-bottom: 1px solid var(--toolbox-border);
font-variant-numeric: tabular-nums;
}
.timeline-list > div:last-child {
border-bottom: 0;
}
.timeline-list > div.active {
background: color-mix(in srgb, var(--toolbox-accent) 10%, transparent);
}
.timeline-list time {
color: var(--toolbox-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.77rem;
}
.migration-options {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--toolbox-border);
}
.health-report {
margin-top: 0.8rem;
}
.health-report li strong {
text-transform: uppercase;
font-size: 0.68rem;
}
.compact-summary {
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));
@@ -730,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 {
@@ -757,6 +989,17 @@ button:disabled {
.input-actions input {
flex-basis: 100%;
}
.time-mode,
.absolute-time-grid,
.time-readout {
grid-template-columns: 1fr;
}
.timeline-list > div {
grid-template-columns: 2.5rem 1fr;
}
.timeline-list time {
grid-column: 1 / -1;
}
.workspace-tabs small {
display: none;
}
+4 -2
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.auth-tools",
"name": "OTP & Passkey Tools",
"version": "0.1.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.1.0";
export const APPLICATION_VERSION = "0.3.1";
+585
View File
@@ -0,0 +1,585 @@
import {
base64UrlToBytes,
bytesToArrayBuffer,
bytesToHex,
utf8ToBytes,
} from "../crypto/encoding";
import type { CborValue } from "./cbor";
import { parseAttestationObject, parseClientData } from "./parser";
import {
bytesEqual,
digest,
importCoseKey,
type VerificationCheck,
} from "./verify";
import {
attestationAlgorithm,
certificatePublicKeyBytes,
derChildren,
extensionContainsBytes,
readDerElement,
verifyCertificateSignature,
} from "./x509";
export interface AttestationVerificationInput {
attestationObject: string;
clientDataJSON: string;
expectedChallenge: string;
expectedOrigin: string;
expectedRpId: string;
requireUserVerification?: boolean;
}
export interface AttestationVerificationResult {
verified: boolean;
format: string;
attestationType:
| "none"
| "self"
| "basic"
| "attca"
| "anonymous"
| "unknown";
trustEstablished: boolean;
aaguid?: string;
checks: VerificationCheck[];
certificates: number;
}
function concatenate(...parts: Uint8Array[]): Uint8Array {
const result = new Uint8Array(
parts.reduce((total, part) => total + part.length, 0),
);
let offset = 0;
for (const part of parts) {
result.set(part, offset);
offset += part.length;
}
return result;
}
function number(statement: Map<CborValue, CborValue>, key: string): number {
const value = statement.get(key);
if (typeof value !== "number")
throw new Error(`Attestation statement ${key} is missing.`);
return value;
}
function bytes(statement: Map<CborValue, CborValue>, key: string): Uint8Array {
const value = statement.get(key);
if (!(value instanceof Uint8Array))
throw new Error(`Attestation statement ${key} is missing.`);
return value;
}
function certificates(statement: Map<CborValue, CborValue>): Uint8Array[] {
const value = statement.get("x5c");
if (value === undefined) return [];
if (
!Array.isArray(value) ||
value.length === 0 ||
value.length > 20 ||
value.some((item) => !(item instanceof Uint8Array))
)
throw new Error(
"Attestation x5c must contain between 1 and 20 certificates.",
);
return value as Uint8Array[];
}
function check(
checks: VerificationCheck[],
name: string,
pass: boolean,
detail: string,
): void {
checks.push({ name, status: pass ? "pass" : "fail", detail });
}
function information(
checks: VerificationCheck[],
name: string,
detail: string,
warning = false,
): void {
checks.push({ name, status: warning ? "warning" : "information", detail });
}
async function verifyWithCoseKey(
key: Map<CborValue, CborValue>,
signature: Uint8Array,
signed: Uint8Array,
): Promise<boolean> {
const imported = await importCoseKey(key);
return crypto.subtle.verify(
imported.algorithm,
imported.key,
bytesToArrayBuffer(imported.normalizeSignature(signature)),
bytesToArrayBuffer(signed),
);
}
function coseEcPublicKey(key: Map<CborValue, CborValue>): Uint8Array {
const x = key.get(-2);
const y = key.get(-3);
if (!(x instanceof Uint8Array) || !(y instanceof Uint8Array))
throw new Error("Credential is not an EC public key.");
return concatenate(Uint8Array.of(4), x, y);
}
function unsignedInteger(value: Uint8Array): Uint8Array {
let offset = 0;
while (offset < value.length - 1 && value[offset] === 0) offset += 1;
return value.slice(offset);
}
function coseMatchesCertificate(
key: Map<CborValue, CborValue>,
certificate: Uint8Array,
): boolean {
const certificateKey = certificatePublicKeyBytes(certificate);
if (key.get(1) === 2) return bytesEqual(certificateKey, coseEcPublicKey(key));
if (key.get(1) === 3) {
const sequence = readDerElement(certificateKey, 0);
const integers = derChildren(certificateKey, sequence);
const modulus = integers[0];
const exponent = integers[1];
const coseModulus = key.get(-1);
const coseExponent = key.get(-2);
if (
sequence.tag !== 0x30 ||
sequence.end !== certificateKey.length ||
!modulus ||
modulus.tag !== 0x02 ||
!exponent ||
exponent.tag !== 0x02 ||
!(coseModulus instanceof Uint8Array) ||
!(coseExponent instanceof Uint8Array)
)
throw new Error("RSA credential or certificate key is malformed.");
return (
bytesEqual(
unsignedInteger(
certificateKey.slice(modulus.contentStart, modulus.end),
),
unsignedInteger(coseModulus),
) &&
bytesEqual(
unsignedInteger(
certificateKey.slice(exponent.contentStart, exponent.end),
),
unsignedInteger(coseExponent),
)
);
}
throw new Error("Certificate key comparison supports EC2 and RSA keys.");
}
function readTpmU16(value: Uint8Array, offset: number): number {
if (offset + 2 > value.length) throw new Error("TPM structure is truncated.");
return (value[offset]! << 8) | value[offset + 1]!;
}
function readTpmU32(value: Uint8Array, offset: number): number {
if (offset + 4 > value.length) throw new Error("TPM structure is truncated.");
return new DataView(value.buffer, value.byteOffset + offset, 4).getUint32(
0,
false,
);
}
function readTpm2b(value: Uint8Array, state: { offset: number }): Uint8Array {
const length = readTpmU16(value, state.offset);
state.offset += 2;
if (state.offset + length > value.length)
throw new Error("TPM2B value is truncated.");
const result = value.slice(state.offset, state.offset + length);
state.offset += length;
return result;
}
function tpmHashFromNameAlgorithm(
name: number,
): "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512" {
if (name === 0x0004) return "SHA-1";
if (name === 0x000b) return "SHA-256";
if (name === 0x000c) return "SHA-384";
if (name === 0x000d) return "SHA-512";
throw new Error(`Unsupported TPM name algorithm 0x${name.toString(16)}.`);
}
function skipTpmScheme(value: Uint8Array, state: { offset: number }): void {
const algorithm = readTpmU16(value, state.offset);
state.offset += 2;
if (algorithm !== 0x0010) state.offset += 2;
if (state.offset > value.length) throw new Error("TPM scheme is truncated.");
}
function skipTpmSymmetric(value: Uint8Array, state: { offset: number }): void {
const algorithm = readTpmU16(value, state.offset);
state.offset += 2;
if (algorithm !== 0x0010) state.offset += 4;
if (state.offset > value.length)
throw new Error("TPM symmetric definition is truncated.");
}
function tpmPublicKeyMatches(
pubArea: Uint8Array,
key: Map<CborValue, CborValue>,
): boolean {
const type = readTpmU16(pubArea, 0);
const state = { offset: 8 };
readTpm2b(pubArea, state);
if (type === 0x0001) {
skipTpmSymmetric(pubArea, state);
skipTpmScheme(pubArea, state);
state.offset += 2;
const exponent = readTpmU32(pubArea, state.offset) || 65_537;
state.offset += 4;
const modulus = readTpm2b(pubArea, state);
const coseModulus = key.get(-1);
const coseExponent = key.get(-2);
if (
state.offset !== pubArea.length ||
key.get(1) !== 3 ||
!(coseModulus instanceof Uint8Array) ||
!(coseExponent instanceof Uint8Array)
)
return false;
let coseExponentNumber = 0;
for (const byte of coseExponent)
coseExponentNumber = coseExponentNumber * 256 + byte;
return bytesEqual(modulus, coseModulus) && exponent === coseExponentNumber;
}
if (type === 0x0023) {
skipTpmSymmetric(pubArea, state);
skipTpmScheme(pubArea, state);
const curve = readTpmU16(pubArea, state.offset);
state.offset += 2;
skipTpmScheme(pubArea, state);
const x = readTpm2b(pubArea, state);
const y = readTpm2b(pubArea, state);
const curves = new Map([
[0x0003, 1],
[0x0004, 2],
[0x0005, 3],
]);
return (
state.offset === pubArea.length &&
key.get(1) === 2 &&
key.get(-1) === curves.get(curve) &&
key.get(-2) instanceof Uint8Array &&
key.get(-3) instanceof Uint8Array &&
bytesEqual(x, key.get(-2) as Uint8Array) &&
bytesEqual(y, key.get(-3) as Uint8Array)
);
}
throw new Error(`Unsupported TPM public key type 0x${type.toString(16)}.`);
}
async function verifyTpmBindings(
certInfo: Uint8Array,
pubArea: Uint8Array,
signed: Uint8Array,
coseAlgorithm: number,
credentialKey: Map<CborValue, CborValue>,
): Promise<{ extraData: boolean; name: boolean }> {
if (
readTpmU32(certInfo, 0) !== 0xff544347 ||
readTpmU16(certInfo, 4) !== 0x8017
)
throw new Error("TPM certInfo has the wrong magic or type.");
const state = { offset: 6 };
readTpm2b(certInfo, state);
const extraData = readTpm2b(certInfo, state);
state.offset += 17 + 8;
const name = readTpm2b(certInfo, state);
readTpm2b(certInfo, state);
if (state.offset !== certInfo.length)
throw new Error("TPM certInfo has trailing data.");
const nameAlgorithm = readTpmU16(pubArea, 2);
const hashAlgorithm = tpmHashFromNameAlgorithm(nameAlgorithm);
const expectedExtra = await digest(
signed,
attestationAlgorithm(coseAlgorithm).hash,
);
const expectedName = concatenate(
Uint8Array.of(nameAlgorithm >>> 8, nameAlgorithm & 0xff),
await digest(pubArea, hashAlgorithm),
);
if (!tpmPublicKeyMatches(pubArea, credentialKey))
throw new Error(
"TPM pubArea public key does not match the credential key.",
);
return {
extraData: bytesEqual(extraData, expectedExtra),
name: bytesEqual(name, expectedName),
};
}
export async function verifyAttestation(
input: AttestationVerificationInput,
): Promise<AttestationVerificationResult> {
const checks: VerificationCheck[] = [];
const clientBytes = base64UrlToBytes(input.clientDataJSON);
const client = parseClientData(clientBytes);
const parsed = parseAttestationObject(input.attestationObject);
const credential = parsed.authenticator.attestedCredential;
check(
checks,
"Ceremony type",
client.type === "webauthn.create",
`Received ${client.type}.`,
);
let challenge = false;
try {
challenge = bytesEqual(
base64UrlToBytes(client.challenge),
base64UrlToBytes(input.expectedChallenge),
);
} catch {
/* reported below */
}
check(
checks,
"Challenge",
challenge,
challenge ? "Challenge matches exactly." : "Challenge does not match.",
);
check(
checks,
"Origin",
client.origin === input.expectedOrigin,
`Received ${client.origin}.`,
);
check(
checks,
"Cross-origin",
client.crossOrigin !== true,
client.crossOrigin === true
? "Client data marks this ceremony cross-origin."
: "Ceremony is not marked cross-origin.",
);
check(
checks,
"RP ID hash",
bytesToHex(await digest(utf8ToBytes(input.expectedRpId))) ===
parsed.authenticator.rpIdHash,
`Expected SHA-256(${input.expectedRpId}).`,
);
check(
checks,
"User presence",
parsed.authenticator.flags.userPresent,
"Authenticator UP flag must be set.",
);
if (input.requireUserVerification)
check(
checks,
"User verification",
parsed.authenticator.flags.userVerified,
"User verification was required.",
);
check(
checks,
"Attested credential data",
Boolean(credential),
credential
? `AAGUID ${credential.aaguid}.`
: "Registration has no credential key.",
);
const chain = certificates(parsed.statement);
const clientHash = await digest(clientBytes);
const signed = concatenate(parsed.authenticator.raw, clientHash);
let type: AttestationVerificationResult["attestationType"] = "unknown";
try {
if (!credential) throw new Error("Attested credential data is required.");
if (parsed.format === "none") {
const empty = parsed.statement.size === 0;
check(
checks,
"None statement",
empty,
empty
? "Attestation statement is empty as required."
: "None attestation must have an empty statement.",
);
type = "none";
} else if (parsed.format === "packed") {
const alg = number(parsed.statement, "alg");
const signature = bytes(parsed.statement, "sig");
if (chain.length) {
check(
checks,
"Packed signature",
await verifyCertificateSignature(chain[0]!, alg, signature, signed),
"Signature over authenticatorData || clientDataHash.",
);
type = "basic";
} else {
check(
checks,
"Packed self signature",
await verifyWithCoseKey(
credential.credentialPublicKey,
signature,
signed,
),
"Self attestation verifies with the credential public key.",
);
type = "self";
}
} else if (parsed.format === "fido-u2f") {
if (chain.length !== 1)
throw new Error(
"FIDO U2F requires exactly one attestation certificate.",
);
const publicKey = coseEcPublicKey(credential.credentialPublicKey);
const verificationData = concatenate(
Uint8Array.of(0),
parsed.authenticator.raw.slice(0, 32),
clientHash,
base64UrlToBytes(credential.credentialId),
publicKey,
);
check(
checks,
"FIDO U2F signature",
await verifyCertificateSignature(
chain[0]!,
-7,
bytes(parsed.statement, "sig"),
verificationData,
),
"U2F registration signature and credential bindings verify.",
);
type = "basic";
} else if (parsed.format === "android-key") {
if (!chain.length)
throw new Error(
"Android Key attestation requires an x5c certificate chain.",
);
const alg = number(parsed.statement, "alg");
check(
checks,
"Android Key signature",
await verifyCertificateSignature(
chain[0]!,
alg,
bytes(parsed.statement, "sig"),
signed,
),
"Signature over authenticatorData || clientDataHash.",
);
check(
checks,
"Android challenge binding",
extensionContainsBytes(
chain[0]!,
"1.3.6.1.4.1.11129.2.1.17",
clientHash,
),
"Certificate key-attestation extension contains clientDataHash.",
);
check(
checks,
"Android credential key",
coseMatchesCertificate(credential.credentialPublicKey, chain[0]!),
"Certificate public key matches the credential key.",
);
type = "basic";
} else if (parsed.format === "apple") {
if (!chain.length)
throw new Error("Apple attestation requires an x5c certificate chain.");
const nonce = await digest(signed);
check(
checks,
"Apple nonce binding",
extensionContainsBytes(chain[0]!, "1.2.840.113635.100.8.2", nonce),
"Apple nonce extension contains SHA-256(authenticatorData || clientDataHash).",
);
check(
checks,
"Apple credential key",
coseMatchesCertificate(credential.credentialPublicKey, chain[0]!),
"Certificate public key matches the credential key.",
);
type = "anonymous";
} else if (parsed.format === "tpm") {
if (!chain.length || parsed.statement.get("ver") !== "2.0")
throw new Error(
"TPM attestation requires version 2.0 and an x5c chain.",
);
const alg = number(parsed.statement, "alg");
const certInfo = bytes(parsed.statement, "certInfo");
const pubArea = bytes(parsed.statement, "pubArea");
check(
checks,
"TPM signature",
await verifyCertificateSignature(
chain[0]!,
alg,
bytes(parsed.statement, "sig"),
certInfo,
),
"Attestation certificate signature over certInfo.",
);
const bindings = await verifyTpmBindings(
certInfo,
pubArea,
signed,
alg,
credential.credentialPublicKey,
);
check(
checks,
"TPM extraData binding",
bindings.extraData,
"certInfo extraData binds authenticatorData and clientDataHash.",
);
check(
checks,
"TPM pubArea name",
bindings.name,
"certInfo name binds the supplied pubArea.",
);
check(
checks,
"TPM credential key",
true,
"pubArea RSA/ECC parameters match the credential public key.",
);
type = "attca";
} else {
throw new Error(`Attestation format ${parsed.format} is not supported.`);
}
} catch (error) {
checks.push({
name: "Attestation statement",
status: "fail",
detail:
error instanceof Error
? error.message
: "Attestation verification failed.",
});
}
const trustEstablished = false;
information(
checks,
"Trust path",
chain.length
? "Certificate signatures are checked, but no imported trust root has established this chain."
: "This format has no certificate trust path.",
chain.length > 0,
);
return {
verified: checks.every((item) => item.status !== "fail"),
format: parsed.format,
attestationType: type,
trustEstablished,
...(credential ? { aaguid: credential.aaguid } : {}),
checks,
certificates: chain.length,
};
}
+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>;
}
+137 -15
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,32 +140,87 @@ export async function createMemoryCredential(input: {
throw new Error(
"Registration response does not contain a credential public key.",
);
const extensionResults = extensionOutputToJson(
credential.getClientExtensionResults(),
) as Record<string, unknown>;
const transports = (credential.response.getTransports?.() ?? []).filter(
(value): value is AuthenticatorTransport =>
[
"ble",
"cable",
"hybrid",
"internal",
"nfc",
"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: coseKeyToJson(attested.credentialPublicKey),
publicKey: publicKeyJson,
signCount: parsed.authenticator.signCount,
transports: (credential.response.getTransports?.() ?? []).filter(
(value): value is AuthenticatorTransport =>
[
"ble",
"cable",
"hybrid",
"internal",
"nfc",
"smart-card",
"usb",
].includes(value),
),
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 };
}
+227
View File
@@ -0,0 +1,227 @@
import {
base64UrlToBytes,
bytesToArrayBuffer,
bytesToBase64Url,
bytesToUtf8,
utf8ToBytes,
} from "../crypto/encoding";
import { certificateSpki } from "./x509";
export interface MetadataStatusReport {
status: string;
effectiveDate?: string;
authenticatorVersion?: number;
certificate?: string;
url?: string;
certificationDescriptor?: string;
certificateNumber?: string;
certificationPolicyVersion?: string;
certificationRequirementsVersion?: string;
}
export interface MetadataEntry {
aaguid?: string;
aaid?: string;
attestationCertificateKeyIdentifiers?: string[];
metadataStatement?: Record<string, unknown>;
statusReports: MetadataStatusReport[];
timeOfLastStatusChange?: string;
}
export interface FidoMetadataSnapshot {
sequenceNumber: number;
nextUpdate: string;
entries: MetadataEntry[];
jwtAlgorithm: string;
signerCertificates: number;
signerCertificateChain: string[];
signatureVerified: boolean;
trustEstablished: false;
warnings: string[];
}
function record(value: unknown, name: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error(`${name} must be an object.`);
return value as Record<string, unknown>;
}
function standardBase64(input: string): Uint8Array {
if (!/^[A-Za-z0-9+/]*={0,2}$/u.test(input))
throw new Error("x5c certificate is not Base64.");
try {
return Uint8Array.from(
atob(input.padEnd(Math.ceil(input.length / 4) * 4, "=")),
(value) => value.charCodeAt(0),
);
} catch {
throw new Error("x5c certificate is not Base64.");
}
}
function jsonPart(value: string, name: string): Record<string, unknown> {
try {
return record(
JSON.parse(bytesToUtf8(base64UrlToBytes(value))) as unknown,
name,
);
} catch (error) {
throw new Error(`${name} is not valid Base64url JSON.`, { cause: error });
}
}
async function verifyJwt(
algorithm: string,
certificate: Uint8Array,
signature: Uint8Array,
signed: Uint8Array,
): Promise<boolean> {
if (algorithm === "RS256") {
const key = await crypto.subtle.importKey(
"spki",
bytesToArrayBuffer(certificateSpki(certificate)),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["verify"],
);
return crypto.subtle.verify(
"RSASSA-PKCS1-v1_5",
key,
bytesToArrayBuffer(signature),
bytesToArrayBuffer(signed),
);
}
if (algorithm === "ES256") {
const key = await crypto.subtle.importKey(
"spki",
bytesToArrayBuffer(certificateSpki(certificate)),
{ name: "ECDSA", namedCurve: "P-256" },
false,
["verify"],
);
return crypto.subtle.verify(
{ name: "ECDSA", hash: "SHA-256" },
key,
bytesToArrayBuffer(signature),
bytesToArrayBuffer(signed),
);
}
throw new Error(`Unsupported Metadata BLOB JWT algorithm ${algorithm}.`);
}
export async function importFidoMetadataBlob(
input: string,
): Promise<FidoMetadataSnapshot> {
if (input.length > 32 * 1024 * 1024)
throw new Error("Metadata BLOB exceeds the 32 MiB safety limit.");
const compact = input.trim();
const parts = compact.split(".");
if (parts.length !== 3)
throw new Error("Metadata BLOB must be a compact JWS.");
const header = jsonPart(parts[0]!, "Metadata JWT header");
const payload = jsonPart(parts[1]!, "Metadata JWT payload");
const algorithm = typeof header.alg === "string" ? header.alg : "";
const x5c = Array.isArray(header.x5c) ? header.x5c : [];
if (
x5c.length === 0 ||
x5c.length > 20 ||
x5c.some((value) => typeof value !== "string")
)
throw new Error("Metadata JWT requires a bounded x5c signer chain.");
const certificates = (x5c as string[]).map(standardBase64);
const signatureVerified = await verifyJwt(
algorithm,
certificates[0]!,
base64UrlToBytes(parts[2]!),
utf8ToBytes(`${parts[0]}.${parts[1]}`),
);
const sequenceNumber = Number(payload.no);
const nextUpdate =
typeof payload.nextUpdate === "string" ? payload.nextUpdate : "";
if (
!Number.isSafeInteger(sequenceNumber) ||
sequenceNumber < 0 ||
!/^\d{4}-\d{2}-\d{2}$/u.test(nextUpdate)
)
throw new Error("Metadata payload has invalid no or nextUpdate fields.");
if (!Array.isArray(payload.entries) || payload.entries.length > 100_000)
throw new Error(
"Metadata payload entries are missing or exceed the limit.",
);
const entries = payload.entries.map((raw, index): MetadataEntry => {
const entry = record(raw, `Metadata entry ${index + 1}`);
const statusReports = Array.isArray(entry.statusReports)
? entry.statusReports.map((status, statusIndex) => {
const report = record(
status,
`Metadata status ${index + 1}.${statusIndex + 1}`,
);
if (typeof report.status !== "string")
throw new Error(
`Metadata status ${index + 1}.${statusIndex + 1} has no status.`,
);
return report as unknown as MetadataStatusReport;
})
: [];
return {
...(typeof entry.aaguid === "string"
? { aaguid: entry.aaguid.toLowerCase() }
: {}),
...(typeof entry.aaid === "string" ? { aaid: entry.aaid } : {}),
...(Array.isArray(entry.attestationCertificateKeyIdentifiers)
? {
attestationCertificateKeyIdentifiers:
entry.attestationCertificateKeyIdentifiers.filter(
(value): value is string => typeof value === "string",
),
}
: {}),
...(entry.metadataStatement &&
typeof entry.metadataStatement === "object" &&
!Array.isArray(entry.metadataStatement)
? {
metadataStatement: entry.metadataStatement as Record<
string,
unknown
>,
}
: {}),
statusReports,
...(typeof entry.timeOfLastStatusChange === "string"
? { timeOfLastStatusChange: entry.timeOfLastStatusChange }
: {}),
};
});
const warnings: string[] = [];
if (!signatureVerified)
warnings.push("The Metadata BLOB JWS signature is invalid.");
if (Date.parse(`${nextUpdate}T23:59:59Z`) < Date.now())
warnings.push(`The Metadata BLOB expired after ${nextUpdate}.`);
warnings.push(
"The JWS signer certificate was not chained to an independently configured FIDO trust root; signature validity is not trust establishment.",
);
return {
sequenceNumber,
nextUpdate,
entries,
jwtAlgorithm: algorithm,
signerCertificates: certificates.length,
signerCertificateChain: certificates.map(bytesToBase64Url),
signatureVerified,
trustEstablished: false,
warnings,
};
}
export function findMetadataEntry(
snapshot: FidoMetadataSnapshot,
aaguid: string,
): MetadataEntry | undefined {
return snapshot.entries.find(
(entry) => entry.aaguid === aaguid.toLowerCase(),
);
}
export function metadataSummary(snapshot: FidoMetadataSnapshot): string {
return `${snapshot.entries.length} entries · sequence ${snapshot.sequenceNumber} · next update ${snapshot.nextUpdate} · signer ${snapshot.signatureVerified ? "valid" : "invalid"} · snapshot ${bytesToBase64Url(utf8ToBytes(String(snapshot.sequenceNumber))).slice(0, 8)}`;
}
+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,
};
}
+8 -5
View File
@@ -34,7 +34,7 @@ export interface AssertionVerificationResult {
signCount: number;
}
function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
export function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
if (left.byteLength !== right.byteLength) return false;
let difference = 0;
for (let index = 0; index < left.byteLength; index += 1)
@@ -42,9 +42,12 @@ function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
return difference === 0;
}
async function digest(bytes: Uint8Array): Promise<Uint8Array> {
export async function digest(
bytes: Uint8Array,
algorithm = "SHA-256",
): Promise<Uint8Array> {
return new Uint8Array(
await crypto.subtle.digest("SHA-256", bytesToArrayBuffer(bytes)),
await crypto.subtle.digest(algorithm, bytesToArrayBuffer(bytes)),
);
}
@@ -98,7 +101,7 @@ export function coseKeyToJson(
return result;
}
function derEcdsaToRaw(signature: Uint8Array, size = 32): Uint8Array {
export function derEcdsaToRaw(signature: Uint8Array, size = 32): Uint8Array {
if (signature[0] !== 0x30)
throw new Error("ECDSA signature is not a DER sequence.");
let offset = 1;
@@ -148,7 +151,7 @@ function derEcdsaToRaw(signature: Uint8Array, size = 32): Uint8Array {
return output;
}
async function importCoseKey(map: Map<CborValue, CborValue>): Promise<{
export async function importCoseKey(map: Map<CborValue, CborValue>): Promise<{
key: CryptoKey;
algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams;
normalizeSignature: (signature: Uint8Array) => Uint8Array;
+353
View File
@@ -0,0 +1,353 @@
import { bytesToArrayBuffer } from "../crypto/encoding";
import { derEcdsaToRaw } from "./verify";
export interface DerElement {
tag: number;
start: number;
contentStart: number;
end: number;
}
export function readDerElement(bytes: Uint8Array, offset: number): DerElement {
if (offset < 0 || offset + 2 > bytes.length)
throw new Error("DER value is truncated.");
const start = offset;
const tag = bytes[offset++]!;
let length = bytes[offset++]!;
if (length & 0x80) {
const count = length & 0x7f;
if (count === 0 || count > 4 || offset + count > bytes.length)
throw new Error("DER length is invalid.");
length = 0;
for (let index = 0; index < count; index += 1)
length = length * 256 + bytes[offset++]!;
}
if (length < 0 || offset + length > bytes.length)
throw new Error("DER content is truncated.");
return { tag, start, contentStart: offset, end: offset + length };
}
export function derChildren(
bytes: Uint8Array,
parent: DerElement,
): DerElement[] {
const children: DerElement[] = [];
let offset = parent.contentStart;
while (offset < parent.end) {
const child = readDerElement(bytes, offset);
if (child.end > parent.end)
throw new Error("DER child exceeds its parent.");
children.push(child);
offset = child.end;
}
return children;
}
function certificateTbs(bytes: Uint8Array): DerElement {
const certificate = readDerElement(bytes, 0);
if (certificate.tag !== 0x30 || certificate.end !== bytes.length)
throw new Error("Attestation certificate is not a complete DER sequence.");
const tbs = derChildren(bytes, certificate)[0];
if (!tbs || tbs.tag !== 0x30)
throw new Error("Certificate TBSCertificate is missing.");
return tbs;
}
export function certificateSpki(bytes: Uint8Array): Uint8Array {
const fields = derChildren(bytes, certificateTbs(bytes));
let index = fields[0]?.tag === 0xa0 ? 1 : 0;
index += 5; // serial, signature, issuer, validity, subject
const spki = fields[index];
if (!spki || spki.tag !== 0x30)
throw new Error("Certificate SubjectPublicKeyInfo is missing.");
return bytes.slice(spki.start, spki.end);
}
export function certificatePublicKeyBytes(bytes: Uint8Array): Uint8Array {
const spki = readDerElement(certificateSpki(bytes), 0);
const children = derChildren(certificateSpki(bytes), spki);
const bitString = children[1];
if (
!bitString ||
bitString.tag !== 0x03 ||
bitString.contentStart >= bitString.end ||
certificateSpki(bytes)[bitString.contentStart] !== 0
)
throw new Error("Certificate public-key bit string is invalid.");
return certificateSpki(bytes).slice(
bitString.contentStart + 1,
bitString.end,
);
}
function containsBytes(certificate: Uint8Array, value: Uint8Array): boolean {
if (!value.length || value.length > certificate.length) return false;
outer: for (
let offset = 0;
offset <= certificate.length - value.length;
offset += 1
) {
for (let index = 0; index < value.length; index += 1)
if (certificate[offset + index] !== value[index]) continue outer;
return true;
}
return false;
}
function oidBytes(oid: string): Uint8Array {
const arcs = oid.split(".").map(Number);
if (
arcs.length < 2 ||
arcs.some((arc) => !Number.isSafeInteger(arc) || arc < 0) ||
arcs[0]! > 2 ||
(arcs[0]! < 2 && arcs[1]! > 39)
)
throw new Error("Object identifier is invalid.");
const output = [arcs[0]! * 40 + arcs[1]!];
for (const arc of arcs.slice(2)) {
const encoded = [arc & 0x7f];
let remaining = Math.floor(arc / 128);
while (remaining) {
encoded.unshift((remaining & 0x7f) | 0x80);
remaining = Math.floor(remaining / 128);
}
output.push(...encoded);
}
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,
): Uint8Array | undefined {
const fields = derChildren(certificate, certificateTbs(certificate));
const wrapper = fields.find((field) => field.tag === 0xa3);
if (!wrapper) return undefined;
const sequence = derChildren(certificate, wrapper)[0];
if (!sequence || sequence.tag !== 0x30)
throw new Error("Certificate extensions are malformed.");
const expectedOid = oidBytes(oid);
for (const extension of derChildren(certificate, sequence)) {
if (extension.tag !== 0x30)
throw new Error("Certificate extension is malformed.");
const parts = derChildren(certificate, extension);
const name = parts[0];
const value = parts.at(-1);
if (!name || name.tag !== 0x06 || !value || value.tag !== 0x04) continue;
const actualOid = certificate.slice(name.contentStart, name.end);
if (bytesEqual(actualOid, expectedOid))
return certificate.slice(value.contentStart, value.end);
}
return undefined;
}
function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
if (left.length !== right.length) return false;
return left.every((byte, index) => byte === right[index]);
}
export function extensionContainsBytes(
certificate: Uint8Array,
oid: string,
value: Uint8Array,
): boolean {
const extension = certificateExtension(certificate, oid);
return extension ? containsBytes(extension, value) : false;
}
export interface AttestationAlgorithm {
importAlgorithm:
| AlgorithmIdentifier
| RsaHashedImportParams
| EcKeyImportParams;
verifyAlgorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams;
normalizeSignature(signature: Uint8Array): Uint8Array;
hash: "SHA-256" | "SHA-384" | "SHA-512";
}
export function attestationAlgorithm(
coseAlgorithm: number,
): AttestationAlgorithm {
if (coseAlgorithm === -7)
return {
importAlgorithm: { name: "ECDSA", namedCurve: "P-256" },
verifyAlgorithm: { name: "ECDSA", hash: "SHA-256" },
normalizeSignature: (value) => derEcdsaToRaw(value, 32),
hash: "SHA-256",
};
if (coseAlgorithm === -35)
return {
importAlgorithm: { name: "ECDSA", namedCurve: "P-384" },
verifyAlgorithm: { name: "ECDSA", hash: "SHA-384" },
normalizeSignature: (value) => derEcdsaToRaw(value, 48),
hash: "SHA-384",
};
if (coseAlgorithm === -36)
return {
importAlgorithm: { name: "ECDSA", namedCurve: "P-521" },
verifyAlgorithm: { name: "ECDSA", hash: "SHA-512" },
normalizeSignature: (value) => derEcdsaToRaw(value, 66),
hash: "SHA-512",
};
if (coseAlgorithm === -257)
return {
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
verifyAlgorithm: "RSASSA-PKCS1-v1_5",
normalizeSignature: (value) => value,
hash: "SHA-256",
};
if (coseAlgorithm === -258)
return {
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-384" },
verifyAlgorithm: "RSASSA-PKCS1-v1_5",
normalizeSignature: (value) => value,
hash: "SHA-384",
};
if (coseAlgorithm === -259)
return {
importAlgorithm: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-512" },
verifyAlgorithm: "RSASSA-PKCS1-v1_5",
normalizeSignature: (value) => value,
hash: "SHA-512",
};
if (coseAlgorithm === -37)
return {
importAlgorithm: { name: "RSA-PSS", hash: "SHA-256" },
verifyAlgorithm: { name: "RSA-PSS", saltLength: 32 },
normalizeSignature: (value) => value,
hash: "SHA-256",
};
throw new Error(`Unsupported attestation COSE algorithm ${coseAlgorithm}.`);
}
export async function verifyCertificateSignature(
certificate: Uint8Array,
coseAlgorithm: number,
signature: Uint8Array,
signed: Uint8Array,
): Promise<boolean> {
const algorithm = attestationAlgorithm(coseAlgorithm);
const key = await crypto.subtle.importKey(
"spki",
bytesToArrayBuffer(certificateSpki(certificate)),
algorithm.importAlgorithm,
false,
["verify"],
);
return crypto.subtle.verify(
algorithm.verifyAlgorithm,
key,
bytesToArrayBuffer(algorithm.normalizeSignature(signature)),
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();
});
+74 -1
View File
@@ -1,4 +1,10 @@
import { render, screen } from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { Workbench } from "../../src/components/Workbench";
@@ -38,4 +44,71 @@ describe("authentication workbench", () => {
screen.getByRole("button", { name: "Create test credential" }),
).toBeDisabled();
});
it("generates a TOTP at an absolute wall-clock time in an explicit timezone", async () => {
const user = userEvent.setup();
render(<Workbench />);
fireEvent.change(screen.getByLabelText("Base32 secret"), {
target: { value: "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" },
});
fireEvent.change(screen.getByLabelText("Account"), {
target: { value: "rfc-vector" },
});
await user.click(screen.getByRole("button", { name: "Show secret" }));
await waitFor(() =>
expect(
(screen.getByLabelText("Provisioning URI") as HTMLTextAreaElement)
.value,
).toContain("secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"),
);
await user.selectOptions(screen.getByLabelText("Digits"), "8");
await user.click(screen.getByRole("button", { name: "Point in time" }));
fireEvent.change(screen.getByLabelText("Local date and time"), {
target: { value: "1970-01-01T00:00:59" },
});
fireEvent.change(screen.getByLabelText("IANA timezone"), {
target: { value: "UTC" },
});
expect(screen.getByLabelText("Local date and time")).toHaveValue(
"1970-01-01T00:00:59.000",
);
expect(screen.getByLabelText("IANA timezone")).toHaveValue("UTC");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByText("1970-01-01T00:00:59.000Z")).toBeInTheDocument();
expect(
within(screen.getByText("TOTP counter").parentElement!).getByText("1"),
).toBeInTheDocument();
await waitFor(() =>
expect(document.querySelector(".code-display")).toHaveTextContent(
"942 870 82",
),
);
});
it("renders the new OTP diagnostics and attestation workspaces", async () => {
const user = userEvent.setup();
render(<Workbench />);
await user.click(screen.getByRole("button", { name: "Drift & timeline" }));
expect(
screen.getByRole("heading", { name: "OTP timeline" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Clock-drift finder" }),
).toBeInTheDocument();
await user.click(
screen.getByRole("button", { name: /WebAuthn \/ Passkeys/iu }),
);
await user.click(
screen.getByRole("button", { name: "Attestation verifier" }),
);
expect(
screen.getByRole("heading", { name: "Attestation evidence" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "FIDO Metadata BLOB" }),
).toBeInTheDocument();
});
});
+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");
});
});
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import {
credentialHealthReport,
findTotpDrift,
generateTotpTimeline,
} from "../../src/otp/diagnostics";
import type { OtpProfile } from "../../src/otp/profile";
const profile: OtpProfile = {
kind: "totp",
secret: utf8ToBytes("12345678901234567890"),
issuer: "RFC",
account: "vector",
algorithm: "SHA-1",
digits: 8,
period: 30,
epoch: 0,
counter: 0n,
extensions: new Map(),
};
describe("OTP diagnostics", () => {
it("builds an ordered RFC timeline and finds clock drift", async () => {
const timeline = await generateTotpTimeline(profile, 59, 1, 2);
expect(timeline.map((item) => item.delta)).toEqual([-1, 0, 1, 2]);
expect(timeline[1]).toMatchObject({ code: "94287082", counter: 1n });
const drift = await findTotpDrift(timeline[2]!.code, profile, 59, 5);
expect(drift).toMatchObject({ delta: 1, driftSeconds: 30 });
});
it("honors a custom T0 and bounds diagnostic work", async () => {
const shifted = { ...profile, epoch: 30 };
expect((await generateTotpTimeline(shifted, 59, 0, 0))[0]!.counter).toBe(
0n,
);
await expect(
findTotpDrift("12345678", profile, 59, 10_001),
).rejects.toThrow(/10000/u);
});
it("detects duplicate secrets without exposing their fingerprint", async () => {
const report = await credentialHealthReport([
profile,
{ ...profile, issuer: "Other", account: "second" },
]);
expect(report.findings).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "reused-secret", severity: "danger" }),
]),
);
expect(JSON.stringify(report)).not.toContain("31323334");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { utf8ToBytes } from "../../src/crypto/encoding";
import {
decryptOtpBackup,
encryptOtpBackup,
} from "../../src/otp/encrypted-backup";
import type { OtpProfile } from "../../src/otp/profile";
const profile: OtpProfile = {
kind: "totp",
secret: utf8ToBytes("12345678901234567890"),
issuer: "Example",
account: "alice",
algorithm: "SHA-256",
digits: 8,
period: 45,
epoch: 12,
counter: 0n,
extensions: new Map([["image", "none"]]),
};
describe("encrypted OTP backups", () => {
it("round-trips profiles through authenticated encryption", async () => {
const encrypted = await encryptOtpBackup(
[profile],
"correct horse battery staple",
);
expect(encrypted).not.toContain("JBSWY");
const restored = await decryptOtpBackup(
encrypted,
"correct horse battery staple",
);
expect(restored[0]).toMatchObject({
issuer: "Example",
account: "alice",
epoch: 12,
counter: 0n,
});
expect([...restored[0]!.secret]).toEqual([...profile.secret]);
});
it("rejects a wrong password and tampering", async () => {
const encrypted = await encryptOtpBackup(
[profile],
"correct horse battery staple",
);
await expect(
decryptOtpBackup(encrypted, "different secure password"),
).rejects.toThrow(/authentication failed/iu);
const parsed = JSON.parse(encrypted) as { ciphertext: string };
parsed.ciphertext = `${parsed.ciphertext.slice(0, -2)}AA`;
await expect(
decryptOtpBackup(JSON.stringify(parsed), "correct horse battery staple"),
).rejects.toThrow(/authentication failed/iu);
});
});
+77 -1
View File
@@ -1,11 +1,13 @@
import { describe, expect, it } from "vitest";
import {
exportGoogleMigration,
exportCsv,
importCsv,
importGoogleMigrationBatch,
importOtpAuthList,
importPlainPskc,
} from "../../src/otp/migration";
import { utf8ToBytes } from "../../src/crypto/encoding";
import { bytesToBase64Url, utf8ToBytes } from "../../src/crypto/encoding";
import type { OtpProfile } from "../../src/otp/profile";
const profile: OtpProfile = {
@@ -20,6 +22,43 @@ const profile: OtpProfile = {
extensions: new Map(),
};
function varint(value: number): number[] {
const output: number[] = [];
let remaining = value;
do {
let byte = remaining & 0x7f;
remaining >>>= 7;
if (remaining) byte |= 0x80;
output.push(byte);
} while (remaining);
return output;
}
function field(number: number, value: number | Uint8Array): number[] {
return typeof value === "number"
? [...varint(number << 3), ...varint(value)]
: [...varint((number << 3) | 2), ...varint(value.length), ...value];
}
function googlePart(index: number, size = 2, id = 73): string {
const credential = Uint8Array.from([
...field(1, utf8ToBytes("12345678901234567890")),
...field(2, utf8ToBytes(`user-${index}`)),
...field(3, utf8ToBytes("Example")),
...field(4, 1),
...field(5, 1),
...field(6, 2),
]);
const payload = Uint8Array.from([
...field(1, credential),
...field(2, 1),
...field(3, size),
...field(4, index),
...field(5, id),
]);
return `otpauth-migration://offline?data=${bytesToBase64Url(payload)}`;
}
describe("OTP migrations", () => {
it("round-trips the documented CSV including quoting", () => {
const result = importCsv(exportCsv([profile]));
@@ -63,4 +102,41 @@ describe("OTP migrations", () => {
),
).toThrow(/encrypted/iu);
});
it("assembles Google multi-QR batches in index order", () => {
const result = importGoogleMigrationBatch([googlePart(1), googlePart(0)]);
expect(result.profiles.map((item) => item.account)).toEqual([
"user-0",
"user-1",
]);
expect(() => importGoogleMigrationBatch([googlePart(0)])).toThrow(
/missing part 2/iu,
);
expect(() =>
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", () => {
+11
View File
@@ -58,4 +58,15 @@ describe("otpauth profiles", () => {
true,
);
});
it("round-trips a diagnostic RFC 6238 T0 with a portability warning", () => {
const result = parseOtpAuth(
"otpauth://totp/Example?secret=JBSWY3DPEHPK3PXP&t0=1234",
);
expect(result.profile.epoch).toBe(1234);
expect(
result.warnings.some(({ code }) => code === "nonstandard-epoch"),
).toBe(true);
expect(serializeOtpAuth(result.profile)).toContain("t0=1234");
});
});
+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);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import {
formatDateTimeLocal,
parseLocalDateTime,
resolveZonedDateTime,
shiftTimestampByPeriods,
zonedDateTimeCandidates,
} from "../../src/otp/time";
describe("TOTP time travel", () => {
it("parses exact UTC wall-clock values", () => {
const resolved = resolveZonedDateTime("2025-08-19T12:34:56", "UTC");
expect(new Date(resolved.timestampMs).toISOString()).toBe(
"2025-08-19T12:34:56.000Z",
);
expect(formatDateTimeLocal(resolved.timestampMs, "UTC")).toBe(
"2025-08-19T12:34:56",
);
expect(
resolveZonedDateTime("2025-08-19T12:34:56.125", "UTC").timestampMs,
).toBe(Date.UTC(2025, 7, 19, 12, 34, 56, 125));
});
it("respects seasonal IANA timezone offsets", () => {
expect(
new Date(
resolveZonedDateTime("2024-01-15T12:00:00", "America/New_York")
.timestampMs,
).toISOString(),
).toBe("2024-01-15T17:00:00.000Z");
expect(
new Date(
resolveZonedDateTime("2024-07-15T12:00:00", "America/New_York")
.timestampMs,
).toISOString(),
).toBe("2024-07-15T16:00:00.000Z");
});
it("rejects nonexistent DST times and exposes both repeated times", () => {
expect(() =>
resolveZonedDateTime("2024-03-10T02:30:00", "America/New_York"),
).toThrow(/does not exist/iu);
const candidates = zonedDateTimeCandidates(
"2024-11-03T01:30:00",
"America/New_York",
);
expect(candidates.map((value) => new Date(value).toISOString())).toEqual([
"2024-11-03T05:30:00.000Z",
"2024-11-03T06:30:00.000Z",
]);
expect(
new Date(
resolveZonedDateTime("2024-11-03T01:30:00", "America/New_York", "later")
.timestampMs,
).toISOString(),
).toBe("2024-11-03T06:30:00.000Z");
});
it("handles non-hour timezone offsets", () => {
expect(
new Date(
resolveZonedDateTime("2025-08-19T12:00:00", "Asia/Kathmandu")
.timestampMs,
).toISOString(),
).toBe("2025-08-19T06:15:00.000Z");
});
it("validates calendar values, timezone names and bounded period shifts", () => {
expect(() => parseLocalDateTime("2025-02-29T12:00:00")).toThrow(
/valid calendar/iu,
);
expect(() =>
resolveZonedDateTime("2025-01-01T00:00:00", "Mars/Base"),
).toThrow(/timezone/iu);
expect(shiftTimestampByPeriods(90_000, 30, -2)).toBe(30_000);
expect(() => shiftTimestampByPeriods(1_000, 30, -1)).toThrow(
/outside supported time/iu,
);
});
});
+189
View File
@@ -0,0 +1,189 @@
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(
JSON.stringify({
db: {
entries: [
{
type: "totp",
name: "alice",
issuer: "Example",
info: { secret, algo: "SHA1", digits: 6, period: 30 },
},
{ type: "steam", name: "game", issuer: "Steam", info: { secret } },
],
},
}),
);
expect(result.profiles[0]).toMatchObject({
issuer: "Example",
account: "alice",
});
expect(result.warnings[0]).toMatch(/steam/iu);
});
it("imports 2FAS, andOTP and FreeOTP field layouts", () => {
expect(
importTwoFasBackup(
JSON.stringify({
services: [
{
name: "Example",
secret,
otp: {
account: "alice",
issuer: "Issuer",
tokenType: "TOTP",
algorithm: "SHA1",
digits: 6,
period: 30,
},
},
],
}),
).profiles[0],
).toMatchObject({ account: "alice", issuer: "Issuer" });
expect(
importAndOtpBackup(
JSON.stringify([
{
secret,
issuer: "Example",
label: "bob",
type: "TOTP",
algorithm: "SHA1",
digits: 6,
period: 30,
},
]),
).profiles[0]!.account,
).toBe("bob");
expect(
importFreeOtpBackup(
JSON.stringify([
{
secret: [49, 50, 51, 52],
issuerExt: "Example",
label: "carol",
type: "totp",
algo: "SHA1",
digits: 6,
period: 30,
},
]),
).profiles[0]!.account,
).toBe("carol");
});
it("refuses encrypted Aegis content instead of guessing", () => {
expect(() =>
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 { afterEach, describe, expect, it, vi } from "vitest";
import { decodeQrImage } from "../../src/qr/decoder";
describe("QR image decoding", () => {
afterEach(() => vi.unstubAllGlobals());
it("uses native QR detection when available and closes the bitmap", async () => {
const close = vi.fn();
vi.stubGlobal(
"createImageBitmap",
vi.fn().mockResolvedValue({ width: 128, height: 128, close }),
);
vi.stubGlobal(
"BarcodeDetector",
class {
async detect() {
return [
{ rawValue: "otpauth://totp/Example?secret=JBSWY3DPEHPK3PXP" },
];
}
},
);
await expect(
decodeQrImage(new File([new Uint8Array(10)], "qr.png")),
).resolves.toMatch(/^otpauth:/u);
expect(close).toHaveBeenCalledOnce();
});
it("rejects oversized dimensions before pixel extraction", async () => {
const close = vi.fn();
vi.stubGlobal(
"createImageBitmap",
vi.fn().mockResolvedValue({ width: 4097, height: 1, close }),
);
await expect(
decodeQrImage(new File([new Uint8Array(10)], "huge.png")),
).rejects.toThrow(/dimensions/iu);
expect(close).toHaveBeenCalledOnce();
});
});
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import {
bytesToArrayBuffer,
bytesToBase64Url,
utf8ToBytes,
} from "../../src/crypto/encoding";
import { verifyAttestation } from "../../src/webauthn/attestation";
function bytesValue(value: Uint8Array): number[] {
if (value.length < 24) return [0x40 | value.length, ...value];
if (value.length < 256) return [0x58, value.length, ...value];
return [0x59, value.length >>> 8, value.length & 0xff, ...value];
}
function textValue(value: string): number[] {
const encoded = utf8ToBytes(value);
return [0x60 | encoded.length, ...encoded];
}
function noneAttestation(
authenticatorData: Uint8Array,
format = "none",
): Uint8Array {
return Uint8Array.from([
0xa3,
...textValue("fmt"),
...textValue(format),
...textValue("authData"),
...bytesValue(authenticatorData),
...textValue("attStmt"),
0xa0,
]);
}
async function registrationData(): Promise<{
attestationObject: string;
clientDataJSON: string;
challenge: string;
}> {
const challenge = bytesToBase64Url(Uint8Array.of(1, 2, 3, 4));
const client = utf8ToBytes(
JSON.stringify({
type: "webauthn.create",
challenge,
origin: "https://example.test",
crossOrigin: false,
}),
);
const rpHash = new Uint8Array(
await crypto.subtle.digest(
"SHA-256",
bytesToArrayBuffer(utf8ToBytes("example.test")),
),
);
const cose = Uint8Array.from([
0xa5,
0x01,
0x02,
0x03,
0x26,
0x20,
0x01,
0x21,
0x58,
0x20,
...new Uint8Array(32).fill(1),
0x22,
0x58,
0x20,
...new Uint8Array(32).fill(2),
]);
const authData = Uint8Array.from([
...rpHash,
0x41,
0,
0,
0,
0,
...new Uint8Array(16),
0,
1,
7,
...cose,
]);
return {
attestationObject: bytesToBase64Url(noneAttestation(authData)),
clientDataJSON: bytesToBase64Url(client),
challenge,
};
}
describe("WebAuthn attestation verification", () => {
it("verifies a registration with none attestation without claiming trust", async () => {
const data = await registrationData();
const result = await verifyAttestation({
...data,
expectedChallenge: data.challenge,
expectedOrigin: "https://example.test",
expectedRpId: "example.test",
});
expect(result).toMatchObject({
verified: true,
format: "none",
attestationType: "none",
trustEstablished: false,
});
});
it("rejects mismatched ceremony state", async () => {
const data = await registrationData();
const result = await verifyAttestation({
...data,
expectedChallenge: "different",
expectedOrigin: "https://evil.test",
expectedRpId: "evil.test",
});
expect(result.verified).toBe(false);
expect(
result.checks
.filter((check) => check.status === "fail")
.map((check) => check.name),
).toEqual(expect.arrayContaining(["Challenge", "Origin", "RP ID hash"]));
});
});
+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/**"],
},
});