feat: release authentication diagnostics 0.2.0
This commit is contained in:
@@ -4,8 +4,18 @@ All notable changes are documented here.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 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 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 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
|
## 0.1.0 - 2026-08-19
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
OTP & Passkey Tools is `GPL-3.0-or-later`; the full project licence is at `../LICENSE`.
|
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.
|
||||||
|
|||||||
@@ -9,11 +9,16 @@ Authentication material stays in the active tab. The application has no backend,
|
|||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Import of line-delimited provisioning URIs, complete Google Authenticator multi-QR batches, Aegis plaintext JSON, 2FAS JSON, andOTP plaintext JSON, legacy FreeOTP JSON, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Unsupported encrypted vendor formats are rejected rather than guessed.
|
||||||
- Explicit URI-list and CSV export with an unencrypted-secret warning.
|
- Cross-browser QR image decoding through native `BarcodeDetector` when available and a bundled jsQR fallback everywhere else.
|
||||||
|
- Credential-collection health reports for duplicated secrets/labels, weak secret lengths and non-default timing.
|
||||||
|
- 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.
|
- 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 assertion verification for challenge, origin, cross-origin state, RP ID hash, user presence/verification, signature counter, and ES256, RS256, PS256 or Ed25519 signatures.
|
||||||
|
- Layered registration verification for none, packed/self, FIDO U2F, TPM 2.0, Android Key and Apple anonymous attestation, including certificate signatures and format-specific credential/challenge bindings.
|
||||||
|
- Explicit offline FIDO Metadata Service v3 JWT snapshot import, signer verification, AAGUID matching and authenticator status display. Imported metadata never silently establishes a trust root.
|
||||||
- Serializable registration/request option examples, browser capability diagnostics and an ephemeral in-memory live ceremony lab.
|
- Serializable registration/request option examples, browser capability diagnostics and an ephemeral in-memory live ceremony lab.
|
||||||
|
|
||||||
## Security boundary
|
## Security boundary
|
||||||
@@ -26,7 +31,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.
|
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. Camera and USB permissions are not requested. QR images can be selected locally in current browsers; provisioning text can always be pasted.
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md) for the threat model and limitations.
|
See [SECURITY.md](SECURITY.md) for the threat model and limitations.
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@ The Vite base is relative, so both `/` and nested paths work. `npm run toolbox:c
|
|||||||
|
|
||||||
## Static deployment
|
## 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.2.0.zip` over HTTPS. Recommended response headers:
|
||||||
|
|
||||||
```text
|
```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'
|
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'
|
||||||
@@ -62,9 +67,9 @@ 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.
|
- 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.
|
- 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.
|
- Attestation cryptography and metadata status are evidence, not automatic device trust. No trust root or policy is bundled, and FIDO metadata is never downloaded silently.
|
||||||
- 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.
|
- All parts of a Google multi-QR batch must be supplied. Google migration export, Aegis password-vault decryption, FreeOTP 2.x encrypted XML and encrypted PSKC decryption are not claimed.
|
||||||
- CSV and URI exports contain raw OTP seeds. There is no encrypted vault format in this app.
|
- 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.
|
- QR generation supports byte-mode error-correction M through version 10. Larger provisioning records remain available as text exports.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
+11
-8
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
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**.
|
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.
|
||||||
|
|
||||||
## Trust boundaries
|
## 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.
|
- 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.
|
- 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.
|
- 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 but does not silently install a trust root.
|
||||||
|
|
||||||
## Origin isolation
|
## Origin isolation
|
||||||
|
|
||||||
@@ -19,22 +19,25 @@ Downloads and clipboard writes require a user action. Exports are not encrypted.
|
|||||||
|
|
||||||
## Cryptography
|
## 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, public-key import and signature verification use the browser Web Cryptography API. No custom cryptographic primitive is implemented. OTP dynamic truncation, OCRA data-input assembly and bounded DER/TPM structure interpretation are project code covered by tests.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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.
|
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
|
## Parser limits
|
||||||
|
|
||||||
- Provisioning URI: 16 KiB.
|
- Provisioning URI: 16 KiB.
|
||||||
- Migration/CSV/PSKC input: 4 MiB.
|
- Migration/CSV/PSKC input: 4 MiB; vendor/encrypted backup input: 16 MiB.
|
||||||
- Google protobuf: bounded input, varints at most 10 bytes, supported wire types only.
|
- 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.
|
- CBOR: 4 MiB, depth 32, 10,000 items, definite lengths only, duplicate map keys rejected.
|
||||||
- OTP secret: 1 KiB; random generation: 16–1,024 bytes.
|
- OTP secret: 1 KiB; random generation: 16–1,024 bytes.
|
||||||
- Verification windows: TOTP 100 steps maximum; HOTP 10,000 counters maximum.
|
- Verification windows: routine TOTP 100 steps maximum; explicit drift search 10,000 periods; HOTP 10,000 counters maximum.
|
||||||
- QR encoder: version 10 maximum.
|
- 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.
|
||||||
|
|
||||||
## 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.
|
Report vulnerabilities through the repository issue tracker without including real secrets or production assertions.
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Corresponding source and provenance
|
# 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.2.0 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.2.0
|
||||||
|
|
||||||
Build that tag with Node.js 22 and the exact `package-lock.json`:
|
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 |
|
| Source | Revision | Use |
|
||||||
| -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- |
|
| -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
| Toolbox SDK | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a`, packages 0.2.3 | manifest, context, shell and build checks |
|
| 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 4226 | December 2005 | HOTP semantics and vectors |
|
||||||
| RFC 6238 | May 2011 | TOTP semantics and vectors |
|
| RFC 6238 | May 2011 | TOTP semantics and vectors |
|
||||||
| RFC 6287 | June 2011 | OCRA suite grammar, semantics and vectors |
|
| RFC 6287 | June 2011 | OCRA suite grammar, semantics and vectors |
|
||||||
| RFC 6030 | October 2010 | bounded plain-secret PSKC import |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
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.
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ 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-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 |
|
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
|
||||||
|
| `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` | 19.2.6 | MIT | application UI | https://github.com/facebook/react |
|
||||||
| `react-dom` | 19.2.6 | MIT | browser rendering | 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 terms, are collated from the locked install into the release.
|
||||||
|
|
||||||
## QR construction
|
## QR construction
|
||||||
|
|
||||||
|
|||||||
Generated
+12
-5
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "auth-tools",
|
"name": "auth-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "auth-tools",
|
"name": "auth-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.2.3",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||||
|
"jsqr": "1.4.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6"
|
"react-dom": "19.2.6"
|
||||||
},
|
},
|
||||||
@@ -2662,6 +2663,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -3047,9 +3054,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.16",
|
"version": "3.3.18",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
|
||||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|||||||
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "auth-tools",
|
"name": "auth-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect and test OTP and WebAuthn authentication locally in the browser.",
|
"description": "Inspect and test OTP and WebAuthn authentication locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -41,9 +41,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.2.3",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||||
|
"jsqr": "1.4.0",
|
||||||
"react": "19.2.6",
|
"react": "19.2.6",
|
||||||
"react-dom": "19.2.6"
|
"react-dom": "19.2.6"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"nanoid": "3.3.18"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
|
|||||||
@@ -4,8 +4,18 @@ All notable changes are documented here.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 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 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 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
|
## 0.1.0 - 2026-08-19
|
||||||
|
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
OTP & Passkey Tools is `GPL-3.0-or-later`; the full project licence is at `../LICENSE`.
|
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.
|
||||||
|
|||||||
@@ -398,6 +398,216 @@ Installed from: node_modules/@add-ideas/toolbox-shell-react
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
|
==============================================================================
|
||||||
|
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
|
react@19.2.6
|
||||||
Declared licence: MIT
|
Declared licence: MIT
|
||||||
|
|||||||
+12
-7
@@ -9,11 +9,16 @@ Authentication material stays in the active tab. The application has no backend,
|
|||||||
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Import of line-delimited provisioning URIs, complete Google Authenticator multi-QR batches, Aegis plaintext JSON, 2FAS JSON, andOTP plaintext JSON, legacy FreeOTP JSON, the documented CSV shape and RFC 6030 PSKC files containing plain secrets. Unsupported encrypted vendor formats are rejected rather than guessed.
|
||||||
- Explicit URI-list and CSV export with an unencrypted-secret warning.
|
- Cross-browser QR image decoding through native `BarcodeDetector` when available and a bundled jsQR fallback everywhere else.
|
||||||
|
- Credential-collection health reports for duplicated secrets/labels, weak secret lengths and non-default timing.
|
||||||
|
- 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.
|
- 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 assertion verification for challenge, origin, cross-origin state, RP ID hash, user presence/verification, signature counter, and ES256, RS256, PS256 or Ed25519 signatures.
|
||||||
|
- Layered registration verification for none, packed/self, FIDO U2F, TPM 2.0, Android Key and Apple anonymous attestation, including certificate signatures and format-specific credential/challenge bindings.
|
||||||
|
- Explicit offline FIDO Metadata Service v3 JWT snapshot import, signer verification, AAGUID matching and authenticator status display. Imported metadata never silently establishes a trust root.
|
||||||
- Serializable registration/request option examples, browser capability diagnostics and an ephemeral in-memory live ceremony lab.
|
- Serializable registration/request option examples, browser capability diagnostics and an ephemeral in-memory live ceremony lab.
|
||||||
|
|
||||||
## Security boundary
|
## Security boundary
|
||||||
@@ -26,7 +31,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.
|
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. Camera and USB permissions are not requested. QR images can be selected locally in current browsers; provisioning text can always be pasted.
|
||||||
|
|
||||||
See [SECURITY.md](SECURITY.md) for the threat model and limitations.
|
See [SECURITY.md](SECURITY.md) for the threat model and limitations.
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@ The Vite base is relative, so both `/` and nested paths work. `npm run toolbox:c
|
|||||||
|
|
||||||
## Static deployment
|
## 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.2.0.zip` over HTTPS. Recommended response headers:
|
||||||
|
|
||||||
```text
|
```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'
|
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'
|
||||||
@@ -62,9 +67,9 @@ 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.
|
- 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.
|
- 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.
|
- Attestation cryptography and metadata status are evidence, not automatic device trust. No trust root or policy is bundled, and FIDO metadata is never downloaded silently.
|
||||||
- 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.
|
- All parts of a Google multi-QR batch must be supplied. Google migration export, Aegis password-vault decryption, FreeOTP 2.x encrypted XML and encrypted PSKC decryption are not claimed.
|
||||||
- CSV and URI exports contain raw OTP seeds. There is no encrypted vault format in this app.
|
- 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.
|
- QR generation supports byte-mode error-correction M through version 10. Larger provisioning records remain available as text exports.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
+11
-8
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
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**.
|
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.
|
||||||
|
|
||||||
## Trust boundaries
|
## 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.
|
- 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.
|
- 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.
|
- 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 but does not silently install a trust root.
|
||||||
|
|
||||||
## Origin isolation
|
## Origin isolation
|
||||||
|
|
||||||
@@ -19,22 +19,25 @@ Downloads and clipboard writes require a user action. Exports are not encrypted.
|
|||||||
|
|
||||||
## Cryptography
|
## 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, public-key import and signature verification use the browser Web Cryptography API. No custom cryptographic primitive is implemented. OTP dynamic truncation, OCRA data-input assembly and bounded DER/TPM structure interpretation are project code covered by tests.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
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.
|
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
|
## Parser limits
|
||||||
|
|
||||||
- Provisioning URI: 16 KiB.
|
- Provisioning URI: 16 KiB.
|
||||||
- Migration/CSV/PSKC input: 4 MiB.
|
- Migration/CSV/PSKC input: 4 MiB; vendor/encrypted backup input: 16 MiB.
|
||||||
- Google protobuf: bounded input, varints at most 10 bytes, supported wire types only.
|
- 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.
|
- CBOR: 4 MiB, depth 32, 10,000 items, definite lengths only, duplicate map keys rejected.
|
||||||
- OTP secret: 1 KiB; random generation: 16–1,024 bytes.
|
- OTP secret: 1 KiB; random generation: 16–1,024 bytes.
|
||||||
- Verification windows: TOTP 100 steps maximum; HOTP 10,000 counters maximum.
|
- Verification windows: routine TOTP 100 steps maximum; explicit drift search 10,000 periods; HOTP 10,000 counters maximum.
|
||||||
- QR encoder: version 10 maximum.
|
- 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.
|
||||||
|
|
||||||
## 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.
|
Report vulnerabilities through the repository issue tracker without including real secrets or production assertions.
|
||||||
|
|||||||
+6
-3
@@ -1,8 +1,8 @@
|
|||||||
# Corresponding source and provenance
|
# 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.2.0 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.2.0
|
||||||
|
|
||||||
Build that tag with Node.js 22 and the exact `package-lock.json`:
|
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 |
|
| Source | Revision | Use |
|
||||||
| -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- |
|
| -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- |
|
||||||
| Toolbox SDK | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a`, packages 0.2.3 | manifest, context, shell and build checks |
|
| 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 4226 | December 2005 | HOTP semantics and vectors |
|
||||||
| RFC 6238 | May 2011 | TOTP semantics and vectors |
|
| RFC 6238 | May 2011 | TOTP semantics and vectors |
|
||||||
| RFC 6287 | June 2011 | OCRA suite grammar, semantics and vectors |
|
| RFC 6287 | June 2011 | OCRA suite grammar, semantics and vectors |
|
||||||
| RFC 6030 | October 2010 | bounded plain-secret PSKC import |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
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.
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ 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-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 |
|
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 | shared shell, theme and actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
|
||||||
|
| `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` | 19.2.6 | MIT | application UI | https://github.com/facebook/react |
|
||||||
| `react-dom` | 19.2.6 | MIT | browser rendering | 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 terms, are collated from the locked install into the release.
|
||||||
|
|
||||||
## QR construction
|
## QR construction
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.auth-tools",
|
"id": "de.add-ideas.auth-tools",
|
||||||
"name": "OTP & Passkey Tools",
|
"name": "OTP & Passkey Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
|
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
|
|||||||
@@ -35,21 +35,24 @@ export function HelpDialog({
|
|||||||
<p>
|
<p>
|
||||||
OTP seeds are equivalent to a second-factor credential. This app keeps
|
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.
|
them in memory only, masks them by default and clears them on reload.
|
||||||
Exports are explicit and unencrypted; move them only through a secure
|
Plain URI/CSV exports are explicit and unencrypted. The encrypted
|
||||||
channel.
|
backup option authenticates its contents with AES-GCM and a password;
|
||||||
|
it is still not a recovery service or persistent browser vault.
|
||||||
</p>
|
</p>
|
||||||
<h3>OTP clock and counters</h3>
|
<h3>OTP clock and counters</h3>
|
||||||
<p>
|
<p>
|
||||||
TOTP is computed from this device’s clock. A rejected valid-looking
|
TOTP is computed from this device’s clock. A rejected valid-looking
|
||||||
value commonly means clock drift, a different period, or the wrong
|
value commonly means clock drift, a different T0/period, or the wrong
|
||||||
hash/digit profile. HOTP counters must remain synchronized.
|
hash/digit profile. The timeline and drift finder are diagnostics;
|
||||||
|
HOTP counters must remain synchronized.
|
||||||
</p>
|
</p>
|
||||||
<h3>Passkey inspection</h3>
|
<h3>Passkey inspection</h3>
|
||||||
<p>
|
<p>
|
||||||
Inspection is offline. Verification is layered: client-data
|
Inspection is offline. Verification is layered: client-data
|
||||||
expectations, RP ID hash, authenticator flags, counter and
|
expectations, RP ID hash, authenticator flags, counter and
|
||||||
cryptographic signature are reported separately. Metadata is never
|
cryptographic signature are reported separately. Attestation signature
|
||||||
fetched silently.
|
validity, metadata status and trust are distinct results. Metadata is
|
||||||
|
imported explicitly and never fetched silently.
|
||||||
</p>
|
</p>
|
||||||
<h3>Live ceremonies</h3>
|
<h3>Live ceremonies</h3>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -9,10 +9,21 @@ import {
|
|||||||
exportOtpAuthList,
|
exportOtpAuthList,
|
||||||
importCsv,
|
importCsv,
|
||||||
importGoogleMigration,
|
importGoogleMigration,
|
||||||
|
importGoogleMigrationBatch,
|
||||||
importOtpAuthList,
|
importOtpAuthList,
|
||||||
importPlainPskc,
|
importPlainPskc,
|
||||||
type ImportResult,
|
type ImportResult,
|
||||||
} from "../../otp/migration";
|
} from "../../otp/migration";
|
||||||
|
import {
|
||||||
|
credentialHealthReport,
|
||||||
|
exportInteroperabilityVectors,
|
||||||
|
findTotpDrift,
|
||||||
|
generateTotpTimeline,
|
||||||
|
type CredentialHealthReport,
|
||||||
|
type TotpDriftMatch,
|
||||||
|
type TotpTimelineEntry,
|
||||||
|
} from "../../otp/diagnostics";
|
||||||
|
import { decryptOtpBackup, encryptOtpBackup } from "../../otp/encrypted-backup";
|
||||||
import {
|
import {
|
||||||
hotp,
|
hotp,
|
||||||
totp,
|
totp,
|
||||||
@@ -37,6 +48,11 @@ import {
|
|||||||
type TimeDisambiguation,
|
type TimeDisambiguation,
|
||||||
} from "../../otp/time";
|
} from "../../otp/time";
|
||||||
import { qrSvg } from "../../qr/encoder";
|
import { qrSvg } from "../../qr/encoder";
|
||||||
|
import { decodeQrImage } from "../../qr/decoder";
|
||||||
|
import {
|
||||||
|
importVendorBackup,
|
||||||
|
type VendorBackupFormat,
|
||||||
|
} from "../../otp/vendor-backups";
|
||||||
|
|
||||||
type TotpTimeMode = "current" | "relative" | "absolute";
|
type TotpTimeMode = "current" | "relative" | "absolute";
|
||||||
|
|
||||||
@@ -77,23 +93,20 @@ function initialProfile(): OtpProfile {
|
|||||||
|
|
||||||
function detectImport(input: string): ImportResult {
|
function detectImport(input: string): ImportResult {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
if (trimmed.startsWith("otpauth-migration://"))
|
if (trimmed.startsWith("otpauth-migration://")) {
|
||||||
return importGoogleMigration(trimmed);
|
const parts = trimmed
|
||||||
|
.split(/\r?\n/gu)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return parts.length > 1
|
||||||
|
? importGoogleMigrationBatch(parts)
|
||||||
|
: importGoogleMigration(trimmed);
|
||||||
|
}
|
||||||
if (trimmed.startsWith("<")) return importPlainPskc(trimmed);
|
if (trimmed.startsWith("<")) return importPlainPskc(trimmed);
|
||||||
if (/^otpauth:\/\//imu.test(trimmed)) return importOtpAuthList(trimmed);
|
if (/^otpauth:\/\//imu.test(trimmed)) return importOtpAuthList(trimmed);
|
||||||
return importCsv(trimmed);
|
return importCsv(trimmed);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BarcodeResult {
|
|
||||||
rawValue: string;
|
|
||||||
}
|
|
||||||
interface BarcodeDetectorLike {
|
|
||||||
detect(source: ImageBitmap): Promise<BarcodeResult[]>;
|
|
||||||
}
|
|
||||||
interface BarcodeDetectorConstructor {
|
|
||||||
new (options: { formats: string[] }): BarcodeDetectorLike;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OtpWorkspace() {
|
export function OtpWorkspace() {
|
||||||
const [profile, setProfile] = useState<OtpProfile>(initialProfile);
|
const [profile, setProfile] = useState<OtpProfile>(initialProfile);
|
||||||
const [secretText, setSecretText] = useState(() =>
|
const [secretText, setSecretText] = useState(() =>
|
||||||
@@ -114,12 +127,15 @@ export function OtpWorkspace() {
|
|||||||
const [verifyCode, setVerifyCode] = useState("");
|
const [verifyCode, setVerifyCode] = useState("");
|
||||||
const [verification, setVerification] = useState("");
|
const [verification, setVerification] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [mode, setMode] = useState<"credential" | "ocra" | "migration">(
|
const [mode, setMode] = useState<
|
||||||
"credential",
|
"credential" | "diagnostics" | "ocra" | "migration"
|
||||||
);
|
>("credential");
|
||||||
const [migrationText, setMigrationText] = useState("");
|
const [migrationText, setMigrationText] = useState("");
|
||||||
const [imported, setImported] = useState<OtpProfile[]>([]);
|
const [imported, setImported] = useState<OtpProfile[]>([]);
|
||||||
const [warnings, setWarnings] = useState<string[]>([]);
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
const [vendorFormat, setVendorFormat] = useState<VendorBackupFormat>("aegis");
|
||||||
|
const [backupPassword, setBackupPassword] = useState("");
|
||||||
|
const [health, setHealth] = useState<CredentialHealthReport | null>(null);
|
||||||
|
|
||||||
const timeZones = useMemo(() => supportedTimeZones(), []);
|
const timeZones = useMemo(() => supportedTimeZones(), []);
|
||||||
const resolvedTime = useMemo(() => {
|
const resolvedTime = useMemo(() => {
|
||||||
@@ -174,6 +190,7 @@ export function OtpWorkspace() {
|
|||||||
algorithm: profile.algorithm,
|
algorithm: profile.algorithm,
|
||||||
digits: profile.digits,
|
digits: profile.digits,
|
||||||
period: profile.period,
|
period: profile.period,
|
||||||
|
epoch: profile.epoch ?? 0,
|
||||||
timestamp: resolvedTime.timestampMs! / 1000,
|
timestamp: resolvedTime.timestampMs! / 1000,
|
||||||
})
|
})
|
||||||
: hotp({
|
: hotp({
|
||||||
@@ -230,13 +247,24 @@ export function OtpWorkspace() {
|
|||||||
}).format(resolvedTime.timestampMs),
|
}).format(resolvedTime.timestampMs),
|
||||||
utc: new Date(resolvedTime.timestampMs).toISOString(),
|
utc: new Date(resolvedTime.timestampMs).toISOString(),
|
||||||
unixSeconds: seconds,
|
unixSeconds: seconds,
|
||||||
counter: totpCounter(seconds, profile.period).toString(),
|
counter: totpCounter(
|
||||||
remaining: profile.period - (seconds % profile.period),
|
seconds,
|
||||||
|
profile.period,
|
||||||
|
profile.epoch ?? 0,
|
||||||
|
).toString(),
|
||||||
|
remaining:
|
||||||
|
profile.period - ((seconds - (profile.epoch ?? 0)) % profile.period),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [profile.kind, profile.period, resolvedTime.timestampMs, timeZone]);
|
}, [
|
||||||
|
profile.epoch,
|
||||||
|
profile.kind,
|
||||||
|
profile.period,
|
||||||
|
resolvedTime.timestampMs,
|
||||||
|
timeZone,
|
||||||
|
]);
|
||||||
|
|
||||||
const update = <Key extends keyof OtpProfile>(
|
const update = <Key extends keyof OtpProfile>(
|
||||||
key: Key,
|
key: Key,
|
||||||
@@ -281,6 +309,7 @@ export function OtpWorkspace() {
|
|||||||
algorithm: profile.algorithm,
|
algorithm: profile.algorithm,
|
||||||
digits: profile.digits,
|
digits: profile.digits,
|
||||||
period: profile.period,
|
period: profile.period,
|
||||||
|
epoch: profile.epoch ?? 0,
|
||||||
timestamp: resolvedTime.timestampMs! / 1000,
|
timestamp: resolvedTime.timestampMs! / 1000,
|
||||||
window: 2,
|
window: 2,
|
||||||
})
|
})
|
||||||
@@ -346,43 +375,74 @@ export function OtpWorkspace() {
|
|||||||
};
|
};
|
||||||
const scanQr = async (file: File) => {
|
const scanQr = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
if (file.size > 16 * 1024 * 1024)
|
const value = await decodeQrImage(file);
|
||||||
throw new Error("QR image is larger than the 16 MiB safety limit.");
|
setMigrationText((current) =>
|
||||||
const Constructor = (
|
current.trim().startsWith("otpauth-migration://") &&
|
||||||
globalThis as typeof globalThis & {
|
value.startsWith("otpauth-migration://")
|
||||||
BarcodeDetector?: BarcodeDetectorConstructor;
|
? `${current.trim()}\n${value}`
|
||||||
}
|
: value,
|
||||||
).BarcodeDetector;
|
|
||||||
if (!Constructor)
|
|
||||||
throw new Error(
|
|
||||||
"This browser has no BarcodeDetector. Paste the provisioning URI instead.",
|
|
||||||
);
|
);
|
||||||
const bitmap = await createImageBitmap(file);
|
if (!value.startsWith("otpauth-migration://")) performImport(value);
|
||||||
try {
|
|
||||||
if (
|
|
||||||
bitmap.width > 4096 ||
|
|
||||||
bitmap.height > 4096 ||
|
|
||||||
bitmap.width * bitmap.height > 16_777_216
|
|
||||||
)
|
|
||||||
throw new Error(
|
|
||||||
"QR image dimensions exceed the 4096 px / 16-megapixel safety limit.",
|
|
||||||
);
|
|
||||||
const results = await new Constructor({ formats: ["qr_code"] }).detect(
|
|
||||||
bitmap,
|
|
||||||
);
|
|
||||||
if (!results[0]?.rawValue)
|
|
||||||
throw new Error("No QR code was found in the image.");
|
|
||||||
setMigrationText(results[0].rawValue);
|
|
||||||
performImport(results[0].rawValue);
|
|
||||||
} finally {
|
|
||||||
bitmap.close();
|
|
||||||
}
|
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setError(
|
setError(
|
||||||
reason instanceof Error ? reason.message : "QR decoding failed.",
|
reason instanceof Error ? reason.message : "QR decoding failed.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const performVendorImport = () => {
|
||||||
|
try {
|
||||||
|
const result = importVendorBackup(vendorFormat, migrationText);
|
||||||
|
setImported(result.profiles);
|
||||||
|
setWarnings(result.warnings);
|
||||||
|
setHealth(null);
|
||||||
|
setError("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "Vendor backup import failed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const analyzeHealth = async () => {
|
||||||
|
try {
|
||||||
|
setHealth(await credentialHealthReport(imported));
|
||||||
|
setError("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "Health analysis failed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const exportEncrypted = async () => {
|
||||||
|
try {
|
||||||
|
downloadText(
|
||||||
|
"otp-credentials.auth-tools.json",
|
||||||
|
await encryptOtpBackup(imported, backupPassword),
|
||||||
|
"application/json",
|
||||||
|
);
|
||||||
|
setError("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "Backup encryption failed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const importEncrypted = async () => {
|
||||||
|
try {
|
||||||
|
const profiles = await decryptOtpBackup(migrationText, backupPassword);
|
||||||
|
setImported(profiles);
|
||||||
|
setWarnings([
|
||||||
|
"Encrypted backup authenticated and decrypted locally in this tab.",
|
||||||
|
]);
|
||||||
|
setHealth(null);
|
||||||
|
setError("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "Backup decryption failed.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="workspace">
|
<section className="workspace">
|
||||||
@@ -394,6 +454,13 @@ export function OtpWorkspace() {
|
|||||||
>
|
>
|
||||||
Credential lab
|
Credential lab
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "diagnostics" ? "active" : ""}
|
||||||
|
onClick={() => setMode("diagnostics")}
|
||||||
|
>
|
||||||
|
Drift & timeline
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={mode === "ocra" ? "active" : ""}
|
className={mode === "ocra" ? "active" : ""}
|
||||||
@@ -516,6 +583,7 @@ export function OtpWorkspace() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{profile.kind === "totp" ? (
|
{profile.kind === "totp" ? (
|
||||||
|
<>
|
||||||
<label>
|
<label>
|
||||||
<span>Period (seconds)</span>
|
<span>Period (seconds)</span>
|
||||||
<input
|
<input
|
||||||
@@ -528,6 +596,20 @@ export function OtpWorkspace() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>T0 (Unix seconds)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="1"
|
||||||
|
value={profile.epoch ?? 0}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (/^\d+$/u.test(event.target.value))
|
||||||
|
update("epoch", Number(event.target.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<label>
|
<label>
|
||||||
<span>Counter</span>
|
<span>Counter</span>
|
||||||
@@ -790,6 +872,11 @@ export function OtpWorkspace() {
|
|||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
) : mode === "diagnostics" ? (
|
||||||
|
<OtpDiagnostics
|
||||||
|
profile={profile}
|
||||||
|
timestamp={(resolvedTime.timestampMs ?? now) / 1000}
|
||||||
|
/>
|
||||||
) : mode === "ocra" ? (
|
) : mode === "ocra" ? (
|
||||||
<OcraLab />
|
<OcraLab />
|
||||||
) : (
|
) : (
|
||||||
@@ -804,8 +891,8 @@ export function OtpWorkspace() {
|
|||||||
<div className="panel-body">
|
<div className="panel-body">
|
||||||
<p className="hint">
|
<p className="hint">
|
||||||
Paste one or more <code>otpauth://</code> URIs, a Google
|
Paste one or more <code>otpauth://</code> URIs, a Google
|
||||||
Authenticator migration URI, the app’s CSV format, or
|
Authenticator QR batch (one URI per line), the app’s CSV format,
|
||||||
plain-secret PSKC XML.
|
plain-secret PSKC XML, or a supported app backup.
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
className="migration-input"
|
className="migration-input"
|
||||||
@@ -834,6 +921,46 @@ export function OtpWorkspace() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-grid migration-options">
|
||||||
|
<label>
|
||||||
|
<span>App backup format</span>
|
||||||
|
<select
|
||||||
|
value={vendorFormat}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVendorFormat(event.target.value as VendorBackupFormat)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="aegis">Aegis JSON</option>
|
||||||
|
<option value="2fas">2FAS JSON</option>
|
||||||
|
<option value="andotp">andOTP plain JSON</option>
|
||||||
|
<option value="freeotp">FreeOTP legacy JSON</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button compact-button"
|
||||||
|
onClick={performVendorImport}
|
||||||
|
>
|
||||||
|
Import app backup
|
||||||
|
</button>
|
||||||
|
<label className="wide">
|
||||||
|
<span>Encrypted backup password</span>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
minLength={12}
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={backupPassword}
|
||||||
|
onChange={(event) => setBackupPassword(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button compact-button"
|
||||||
|
onClick={() => void importEncrypted()}
|
||||||
|
>
|
||||||
|
Decrypt Auth Tools backup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
@@ -879,7 +1006,11 @@ export function OtpWorkspace() {
|
|||||||
)}
|
)}
|
||||||
{imported.length > 0 && (
|
{imported.length > 0 && (
|
||||||
<div className="panel-footer">
|
<div className="panel-footer">
|
||||||
<p>Exports contain unencrypted secrets.</p>
|
<p>
|
||||||
|
Plain exports reveal secrets. The encrypted format uses
|
||||||
|
authenticated AES-256-GCM and a 600,000-round PBKDF2-SHA-256
|
||||||
|
password key.
|
||||||
|
</p>
|
||||||
<div className="button-row">
|
<div className="button-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -906,7 +1037,41 @@ export function OtpWorkspace() {
|
|||||||
>
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
onClick={() => void analyzeHealth()}
|
||||||
|
>
|
||||||
|
Health report
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
disabled={backupPassword.length < 12}
|
||||||
|
onClick={() => void exportEncrypted()}
|
||||||
|
>
|
||||||
|
Export encrypted
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{health && (
|
||||||
|
<div className="warnings health-report" role="status">
|
||||||
|
<strong>
|
||||||
|
{health.credentialCount} credential health report
|
||||||
|
</strong>
|
||||||
|
{health.findings.length ? (
|
||||||
|
<ul>
|
||||||
|
{health.findings.map((finding, index) => (
|
||||||
|
<li key={`${finding.code}-${index}`}>
|
||||||
|
<strong>{finding.severity}</strong> ·{" "}
|
||||||
|
{finding.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p>No structural weakness or duplicate was detected.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</article>
|
</article>
|
||||||
@@ -916,6 +1081,208 @@ export function OtpWorkspace() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OtpDiagnostics({
|
||||||
|
profile,
|
||||||
|
timestamp,
|
||||||
|
}: {
|
||||||
|
profile: OtpProfile;
|
||||||
|
timestamp: number;
|
||||||
|
}) {
|
||||||
|
const [past, setPast] = useState(3);
|
||||||
|
const [future, setFuture] = useState(3);
|
||||||
|
const [timeline, setTimeline] = useState<TotpTimelineEntry[]>([]);
|
||||||
|
const [driftCode, setDriftCode] = useState("");
|
||||||
|
const [driftWindow, setDriftWindow] = useState(120);
|
||||||
|
const [drift, setDrift] = useState<TotpDriftMatch | null | undefined>();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const buildTimeline = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setTimeline(await generateTotpTimeline(profile, timestamp, past, future));
|
||||||
|
setError("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error
|
||||||
|
? reason.message
|
||||||
|
: "Timeline generation failed.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const searchDrift = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setDrift(await findTotpDrift(driftCode, profile, timestamp, driftWindow));
|
||||||
|
setError("");
|
||||||
|
} catch (reason) {
|
||||||
|
setError(
|
||||||
|
reason instanceof Error ? reason.message : "Drift search failed.",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (profile.kind !== "totp")
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<strong>TOTP diagnostics require a time-based credential.</strong>
|
||||||
|
<span>Switch the credential lab from HOTP to TOTP first.</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="split-grid">
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Bounded time series</p>
|
||||||
|
<h2>OTP timeline</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body compact-form">
|
||||||
|
<div className="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Past periods</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={past}
|
||||||
|
onChange={(event) => setPast(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Future periods</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={future}
|
||||||
|
onChange={(event) => setFuture(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="button-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void buildTimeline()}
|
||||||
|
>
|
||||||
|
Generate timeline
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary-button"
|
||||||
|
disabled={!timeline.length}
|
||||||
|
onClick={() =>
|
||||||
|
downloadText(
|
||||||
|
"otp-test-vectors.json",
|
||||||
|
exportInteroperabilityVectors(profile, timeline),
|
||||||
|
"application/json",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Export test vectors
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="timeline-list">
|
||||||
|
{timeline.map((entry) => (
|
||||||
|
<div
|
||||||
|
className={entry.delta === 0 ? "active" : ""}
|
||||||
|
key={entry.counter.toString()}
|
||||||
|
>
|
||||||
|
<span>{entry.delta > 0 ? `+${entry.delta}` : entry.delta}</span>
|
||||||
|
<strong>{entry.code}</strong>
|
||||||
|
<time>{new Date(entry.startsAt * 1000).toISOString()}</time>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article className="panel">
|
||||||
|
<div className="panel-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Synchronization diagnostic</p>
|
||||||
|
<h2>Clock-drift finder</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body compact-form">
|
||||||
|
<label>
|
||||||
|
<span>Observed OTP code</span>
|
||||||
|
<input
|
||||||
|
inputMode="numeric"
|
||||||
|
value={driftCode}
|
||||||
|
onChange={(event) => setDriftCode(event.target.value)}
|
||||||
|
placeholder={`${profile.digits}-digit code`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Search radius (periods)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="10000"
|
||||||
|
value={driftWindow}
|
||||||
|
onChange={(event) => setDriftWindow(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary-button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void searchDrift()}
|
||||||
|
>
|
||||||
|
Find matching period
|
||||||
|
</button>
|
||||||
|
{drift === null && (
|
||||||
|
<p className="verification">No match in ±{driftWindow} periods.</p>
|
||||||
|
)}
|
||||||
|
{drift && (
|
||||||
|
<dl className="time-readout">
|
||||||
|
<div>
|
||||||
|
<dt>Period offset</dt>
|
||||||
|
<dd>
|
||||||
|
{drift.delta > 0 ? "+" : ""}
|
||||||
|
{drift.delta}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Clock drift</dt>
|
||||||
|
<dd>
|
||||||
|
{drift.driftSeconds > 0 ? "+" : ""}
|
||||||
|
{drift.driftSeconds} seconds
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Matching instant</dt>
|
||||||
|
<dd>{new Date(drift.startsAt * 1000).toISOString()}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Counter</dt>
|
||||||
|
<dd>{drift.counter.toString()}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
<p className="hint">
|
||||||
|
A match proves that this secret produces the code in that period. It
|
||||||
|
does not authenticate where the code came from.
|
||||||
|
</p>
|
||||||
|
{error && (
|
||||||
|
<p className="inline-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function OcraLab() {
|
function OcraLab() {
|
||||||
const [suiteText, setSuiteText] = useState("OCRA-1:HOTP-SHA1-6:QN08");
|
const [suiteText, setSuiteText] = useState("OCRA-1:HOTP-SHA1-6:QN08");
|
||||||
const [secretText, setSecretText] = useState(() =>
|
const [secretText, setSecretText] = useState(() =>
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ import {
|
|||||||
verifyAssertion,
|
verifyAssertion,
|
||||||
type AssertionVerificationResult,
|
type AssertionVerificationResult,
|
||||||
} from "../../webauthn/verify";
|
} from "../../webauthn/verify";
|
||||||
|
import {
|
||||||
|
verifyAttestation,
|
||||||
|
type AttestationVerificationResult,
|
||||||
|
} from "../../webauthn/attestation";
|
||||||
|
import {
|
||||||
|
findMetadataEntry,
|
||||||
|
importFidoMetadataBlob,
|
||||||
|
metadataSummary,
|
||||||
|
type FidoMetadataSnapshot,
|
||||||
|
} from "../../webauthn/metadata";
|
||||||
|
|
||||||
type InspectKind = "client" | "attestation" | "authenticator";
|
type InspectKind = "client" | "attestation" | "authenticator";
|
||||||
|
|
||||||
@@ -69,10 +79,18 @@ const EMPTY_ASSERTION = {
|
|||||||
expectedRpId: typeof location === "undefined" ? "" : location.hostname,
|
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() {
|
export function WebAuthnWorkspace() {
|
||||||
const [mode, setMode] = useState<"inspect" | "verify" | "options" | "live">(
|
const [mode, setMode] = useState<
|
||||||
"inspect",
|
"inspect" | "attestation" | "verify" | "options" | "live"
|
||||||
);
|
>("inspect");
|
||||||
const [inspectKind, setInspectKind] = useState<InspectKind>("client");
|
const [inspectKind, setInspectKind] = useState<InspectKind>("client");
|
||||||
const [inspectInput, setInspectInput] = useState("");
|
const [inspectInput, setInspectInput] = useState("");
|
||||||
const [inspectOutput, setInspectOutput] = useState("");
|
const [inspectOutput, setInspectOutput] = useState("");
|
||||||
@@ -80,6 +98,11 @@ export function WebAuthnWorkspace() {
|
|||||||
const [assertion, setAssertion] = useState(EMPTY_ASSERTION);
|
const [assertion, setAssertion] = useState(EMPTY_ASSERTION);
|
||||||
const [verification, setVerification] =
|
const [verification, setVerification] =
|
||||||
useState<AssertionVerificationResult | null>(null);
|
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>(
|
const [capabilities, setCapabilities] = useState<BrowserCapabilities | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -126,6 +149,37 @@ export function WebAuthnWorkspace() {
|
|||||||
setVerification(null);
|
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 () => {
|
const create = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
@@ -206,6 +260,13 @@ export function WebAuthnWorkspace() {
|
|||||||
>
|
>
|
||||||
Inspector
|
Inspector
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === "attestation" ? "active" : ""}
|
||||||
|
onClick={() => setMode("attestation")}
|
||||||
|
>
|
||||||
|
Attestation verifier
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={mode === "verify" ? "active" : ""}
|
className={mode === "verify" ? "active" : ""}
|
||||||
@@ -308,6 +369,183 @@ export function WebAuthnWorkspace() {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{mode === "verify" && (
|
{mode === "verify" && (
|
||||||
<div className="split-grid verifier-grid">
|
<div className="split-grid verifier-grid">
|
||||||
<article className="panel">
|
<article className="panel">
|
||||||
@@ -566,13 +804,15 @@ export function WebAuthnWorkspace() {
|
|||||||
|
|
||||||
function VerificationReport({
|
function VerificationReport({
|
||||||
result,
|
result,
|
||||||
|
noun = "Assertion",
|
||||||
}: {
|
}: {
|
||||||
result: AssertionVerificationResult;
|
result: Pick<AssertionVerificationResult, "verified" | "checks">;
|
||||||
|
noun?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="verification-report" aria-live="polite">
|
<div className="verification-report" aria-live="polite">
|
||||||
<strong>
|
<strong>
|
||||||
{result.verified ? "Assertion verified" : "Assertion rejected"}
|
{result.verified ? `${noun} verified` : `${noun} rejected`}
|
||||||
</strong>
|
</strong>
|
||||||
<ul>
|
<ul>
|
||||||
{result.checks.map((check) => (
|
{result.checks.map((check) => (
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-6
@@ -7,6 +7,15 @@ export interface ImportResult {
|
|||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GoogleMigrationPart {
|
||||||
|
profiles: OtpProfile[];
|
||||||
|
warnings: string[];
|
||||||
|
version: bigint;
|
||||||
|
batchSize: number;
|
||||||
|
batchIndex: number;
|
||||||
|
batchId: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_IMPORT_BYTES = 4 * 1024 * 1024;
|
const MAX_IMPORT_BYTES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
function utf8(bytes: Uint8Array): string {
|
function utf8(bytes: Uint8Array): string {
|
||||||
@@ -144,7 +153,7 @@ function decodeStandardBase64(input: string): Uint8Array {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importGoogleMigration(input: string): ImportResult {
|
function parseGoogleMigrationPart(input: string): GoogleMigrationPart {
|
||||||
if (input.length > MAX_IMPORT_BYTES * 2)
|
if (input.length > MAX_IMPORT_BYTES * 2)
|
||||||
throw new Error("Migration URI is too large.");
|
throw new Error("Migration URI is too large.");
|
||||||
const url = new URL(input);
|
const url = new URL(input);
|
||||||
@@ -162,11 +171,15 @@ export function importGoogleMigration(input: string): ImportResult {
|
|||||||
warnings.push(
|
warnings.push(
|
||||||
`Migration payload version ${version.toString()} is not documented by this importer.`,
|
`Migration payload version ${version.toString()} is not documented by this importer.`,
|
||||||
);
|
);
|
||||||
const batchSize = firstInt(fields, 3, 1n);
|
const batchSizeValue = firstInt(fields, 3, 1n);
|
||||||
if (batchSize > 1n)
|
const batchIndexValue = firstInt(fields, 4, 0n);
|
||||||
warnings.push(
|
const batchId = firstInt(fields, 5, 0n);
|
||||||
`This is one part of a ${batchSize.toString()}-QR migration batch; import all parts.`,
|
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 parameters = fields.get(1) ?? [];
|
||||||
const profiles = parameters.map((value) => {
|
const profiles = parameters.map((value) => {
|
||||||
if (!(value instanceof Uint8Array))
|
if (!(value instanceof Uint8Array))
|
||||||
@@ -175,7 +188,64 @@ export function importGoogleMigration(input: string): ImportResult {
|
|||||||
});
|
});
|
||||||
if (!profiles.length)
|
if (!profiles.length)
|
||||||
throw new Error("Migration payload contains no OTP credentials.");
|
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 csvRows(input: string): string[][] {
|
function csvRows(input: string): string[][] {
|
||||||
|
|||||||
+17
-5
@@ -55,7 +55,9 @@ function counterBytes(counter: bigint): Uint8Array {
|
|||||||
return output;
|
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);
|
validateSecret(options.secret);
|
||||||
const digits = options.digits ?? 6;
|
const digits = options.digits ?? 6;
|
||||||
const algorithm = options.algorithm ?? "SHA-1";
|
const algorithm = options.algorithm ?? "SHA-1";
|
||||||
@@ -67,21 +69,29 @@ export async function hotp(options: HotpOptions): Promise<string> {
|
|||||||
false,
|
false,
|
||||||
["sign"],
|
["sign"],
|
||||||
);
|
);
|
||||||
|
return async (counter: bigint): Promise<string> => {
|
||||||
const digest = new Uint8Array(
|
const digest = new Uint8Array(
|
||||||
await crypto.subtle.sign(
|
await crypto.subtle.sign(
|
||||||
"HMAC",
|
"HMAC",
|
||||||
key,
|
key,
|
||||||
bytesToArrayBuffer(counterBytes(options.counter)),
|
bytesToArrayBuffer(counterBytes(counter)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const offset = (digest.at(-1) ?? 0) & 0x0f;
|
const offset = (digest.at(-1) ?? 0) & 0x0f;
|
||||||
if (offset + 3 >= digest.length) throw new Error("HMAC result is too short.");
|
if (offset + 3 >= digest.length)
|
||||||
|
throw new Error("HMAC result is too short.");
|
||||||
const binary =
|
const binary =
|
||||||
((digest[offset]! & 0x7f) << 24) |
|
((digest[offset]! & 0x7f) << 24) |
|
||||||
((digest[offset + 1]! & 0xff) << 16) |
|
((digest[offset + 1]! & 0xff) << 16) |
|
||||||
((digest[offset + 2]! & 0xff) << 8) |
|
((digest[offset + 2]! & 0xff) << 8) |
|
||||||
(digest[offset + 3]! & 0xff);
|
(digest[offset + 3]! & 0xff);
|
||||||
return (binary % 10 ** digits).toString().padStart(digits, "0");
|
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 {
|
export function totpCounter(timestamp: number, period = 30, epoch = 0): bigint {
|
||||||
@@ -131,12 +141,13 @@ export async function verifyTotp(
|
|||||||
options.period,
|
options.period,
|
||||||
options.epoch,
|
options.epoch,
|
||||||
);
|
);
|
||||||
|
const generate = await createHotpGenerator(options);
|
||||||
for (let distance = 0; distance <= window; distance += 1) {
|
for (let distance = 0; distance <= window; distance += 1) {
|
||||||
const deltas = distance === 0 ? [0] : [-distance, distance];
|
const deltas = distance === 0 ? [0] : [-distance, distance];
|
||||||
for (const delta of deltas) {
|
for (const delta of deltas) {
|
||||||
const counter = baseCounter + BigInt(delta);
|
const counter = baseCounter + BigInt(delta);
|
||||||
if (counter < 0n) continue;
|
if (counter < 0n) continue;
|
||||||
const candidate = await hotp({ ...options, counter, digits });
|
const candidate = await generate(counter);
|
||||||
if (candidate === normalized) return { code: candidate, counter, delta };
|
if (candidate === normalized) return { code: candidate, counter, delta };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,10 +166,11 @@ export async function verifyHotp(
|
|||||||
if (!Number.isInteger(lookAhead) || lookAhead < 0 || lookAhead > 10_000) {
|
if (!Number.isInteger(lookAhead) || lookAhead < 0 || lookAhead > 10_000) {
|
||||||
throw new RangeError("HOTP look-ahead must be between 0 and 10000.");
|
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) {
|
for (let delta = 0; delta <= lookAhead; delta += 1) {
|
||||||
const counter = options.counter + BigInt(delta);
|
const counter = options.counter + BigInt(delta);
|
||||||
if (counter > MAX_HOTP_COUNTER) break;
|
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 };
|
if (candidate === normalized) return { code: candidate, counter, delta };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export interface OtpProfile {
|
|||||||
algorithm: OtpHashAlgorithm;
|
algorithm: OtpHashAlgorithm;
|
||||||
digits: number;
|
digits: number;
|
||||||
period: number;
|
period: number;
|
||||||
|
/** Unix seconds used as RFC 6238 T0. Non-zero values are not portable. */
|
||||||
|
epoch?: number;
|
||||||
counter: bigint;
|
counter: bigint;
|
||||||
extensions: ReadonlyMap<string, string>;
|
extensions: ReadonlyMap<string, string>;
|
||||||
}
|
}
|
||||||
@@ -19,6 +21,7 @@ export interface OtpProfileWarning {
|
|||||||
| "short-secret"
|
| "short-secret"
|
||||||
| "nonstandard-digits"
|
| "nonstandard-digits"
|
||||||
| "nonstandard-period"
|
| "nonstandard-period"
|
||||||
|
| "nonstandard-epoch"
|
||||||
| "extension";
|
| "extension";
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
@@ -126,6 +129,10 @@ export function parseOtpAuth(input: string): ParsedOtpAuth {
|
|||||||
if (!Number.isInteger(period) || period < 1 || period > 86_400) {
|
if (!Number.isInteger(period) || period < 1 || period > 86_400) {
|
||||||
throw new Error("TOTP period is outside the supported range.");
|
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");
|
const counterText = parameters.get("counter");
|
||||||
if (kind === "hotp" && counterText === undefined) {
|
if (kind === "hotp" && counterText === undefined) {
|
||||||
throw new Error("HOTP provisioning requires a counter.");
|
throw new Error("HOTP provisioning requires a counter.");
|
||||||
@@ -144,6 +151,7 @@ export function parseOtpAuth(input: string): ParsedOtpAuth {
|
|||||||
"digits",
|
"digits",
|
||||||
"period",
|
"period",
|
||||||
"counter",
|
"counter",
|
||||||
|
"t0",
|
||||||
]);
|
]);
|
||||||
const extensions = new Map(
|
const extensions = new Map(
|
||||||
[...parameters].filter(([name]) => !known.has(name)),
|
[...parameters].filter(([name]) => !known.has(name)),
|
||||||
@@ -178,6 +186,13 @@ export function parseOtpAuth(input: string): ParsedOtpAuth {
|
|||||||
message: "Some authenticator applications ignore non-default periods.",
|
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()) {
|
for (const name of extensions.keys()) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
code: "extension",
|
code: "extension",
|
||||||
@@ -193,6 +208,7 @@ export function parseOtpAuth(input: string): ParsedOtpAuth {
|
|||||||
algorithm,
|
algorithm,
|
||||||
digits,
|
digits,
|
||||||
period,
|
period,
|
||||||
|
epoch,
|
||||||
counter,
|
counter,
|
||||||
extensions,
|
extensions,
|
||||||
},
|
},
|
||||||
@@ -226,6 +242,8 @@ export function serializeOtpAuth(profile: OtpProfile): string {
|
|||||||
if (profile.kind === "totp")
|
if (profile.kind === "totp")
|
||||||
parameters.set("period", profile.period.toString());
|
parameters.set("period", profile.period.toString());
|
||||||
else parameters.set("counter", profile.counter.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) {
|
for (const [name, value] of profile.extensions) {
|
||||||
if (!parameters.has(name)) parameters.set(name, value);
|
if (!parameters.has(name)) parameters.set(name, value);
|
||||||
}
|
}
|
||||||
@@ -256,6 +274,11 @@ export function profileStrength(profile: OtpProfile): {
|
|||||||
if (profile.kind === "totp" && profile.period !== 30) {
|
if (profile.kind === "totp" && profile.period !== 30) {
|
||||||
findings.push("Non-default periods are frequently ignored by importers.");
|
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)
|
if (findings.length === 0)
|
||||||
findings.push("No structural interoperability issue detected.");
|
findings.push("No structural interoperability issue detected.");
|
||||||
return { secretBits, findings };
|
return { secretBits, findings };
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import { base32ToBytes } 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importAegisBackup(input: string): ImportResult {
|
||||||
|
const root = object(parseJson(input), "Aegis backup");
|
||||||
|
if (typeof root.db === "string")
|
||||||
|
throw new Error(
|
||||||
|
"This Aegis vault is encrypted. Export an unencrypted JSON copy before migration.",
|
||||||
|
);
|
||||||
|
const database = object(root.db ?? root, "Aegis database");
|
||||||
|
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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -751,6 +751,49 @@ button:disabled {
|
|||||||
color: var(--toolbox-muted);
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
.tool-dialog {
|
.tool-dialog {
|
||||||
width: min(calc(100% - 2rem), 42rem);
|
width: min(calc(100% - 2rem), 42rem);
|
||||||
max-height: min(46rem, calc(100vh - 2rem));
|
max-height: min(46rem, calc(100vh - 2rem));
|
||||||
@@ -837,6 +880,12 @@ button:disabled {
|
|||||||
.time-readout {
|
.time-readout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.timeline-list > div {
|
||||||
|
grid-template-columns: 2.5rem 1fr;
|
||||||
|
}
|
||||||
|
.timeline-list time {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
.workspace-tabs small {
|
.workspace-tabs small {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.auth-tools",
|
"id": "de.add-ideas.auth-tools",
|
||||||
"name": "OTP & Passkey Tools",
|
"name": "OTP & Passkey Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
|
"description": "Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const APPLICATION_VERSION = "0.1.0";
|
export const APPLICATION_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
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;
|
||||||
|
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,
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ export interface AssertionVerificationResult {
|
|||||||
signCount: number;
|
signCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
|
export function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
|
||||||
if (left.byteLength !== right.byteLength) return false;
|
if (left.byteLength !== right.byteLength) return false;
|
||||||
let difference = 0;
|
let difference = 0;
|
||||||
for (let index = 0; index < left.byteLength; index += 1)
|
for (let index = 0; index < left.byteLength; index += 1)
|
||||||
@@ -42,9 +42,12 @@ function bytesEqual(left: Uint8Array, right: Uint8Array): boolean {
|
|||||||
return difference === 0;
|
return difference === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function digest(bytes: Uint8Array): Promise<Uint8Array> {
|
export async function digest(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
algorithm = "SHA-256",
|
||||||
|
): Promise<Uint8Array> {
|
||||||
return new 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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function derEcdsaToRaw(signature: Uint8Array, size = 32): Uint8Array {
|
export function derEcdsaToRaw(signature: Uint8Array, size = 32): Uint8Array {
|
||||||
if (signature[0] !== 0x30)
|
if (signature[0] !== 0x30)
|
||||||
throw new Error("ECDSA signature is not a DER sequence.");
|
throw new Error("ECDSA signature is not a DER sequence.");
|
||||||
let offset = 1;
|
let offset = 1;
|
||||||
@@ -148,7 +151,7 @@ function derEcdsaToRaw(signature: Uint8Array, size = 32): Uint8Array {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importCoseKey(map: Map<CborValue, CborValue>): Promise<{
|
export async function importCoseKey(map: Map<CborValue, CborValue>): Promise<{
|
||||||
key: CryptoKey;
|
key: CryptoKey;
|
||||||
algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams;
|
algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams;
|
||||||
normalizeSignature: (signature: Uint8Array) => Uint8Array;
|
normalizeSignature: (signature: Uint8Array) => Uint8Array;
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -86,4 +86,29 @@ describe("authentication workbench", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
exportCsv,
|
exportCsv,
|
||||||
importCsv,
|
importCsv,
|
||||||
|
importGoogleMigrationBatch,
|
||||||
importOtpAuthList,
|
importOtpAuthList,
|
||||||
importPlainPskc,
|
importPlainPskc,
|
||||||
} from "../../src/otp/migration";
|
} from "../../src/otp/migration";
|
||||||
import { utf8ToBytes } from "../../src/crypto/encoding";
|
import { bytesToBase64Url, utf8ToBytes } from "../../src/crypto/encoding";
|
||||||
import type { OtpProfile } from "../../src/otp/profile";
|
import type { OtpProfile } from "../../src/otp/profile";
|
||||||
|
|
||||||
const profile: OtpProfile = {
|
const profile: OtpProfile = {
|
||||||
@@ -20,6 +21,43 @@ const profile: OtpProfile = {
|
|||||||
extensions: new Map(),
|
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", () => {
|
describe("OTP migrations", () => {
|
||||||
it("round-trips the documented CSV including quoting", () => {
|
it("round-trips the documented CSV including quoting", () => {
|
||||||
const result = importCsv(exportCsv([profile]));
|
const result = importCsv(exportCsv([profile]));
|
||||||
@@ -63,4 +101,18 @@ describe("OTP migrations", () => {
|
|||||||
),
|
),
|
||||||
).toThrow(/encrypted/iu);
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,4 +58,15 @@ describe("otpauth profiles", () => {
|
|||||||
true,
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
importAegisBackup,
|
||||||
|
importAndOtpBackup,
|
||||||
|
importFreeOtpBackup,
|
||||||
|
importTwoFasBackup,
|
||||||
|
} from "../../src/otp/vendor-backups";
|
||||||
|
|
||||||
|
const secret = "JBSWY3DPEHPK3PXP";
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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"]));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user