kxco-verify 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/LICENSE +202 -0
- package/README.md +163 -0
- package/package.json +72 -0
- package/src/fetch.d.ts +28 -0
- package/src/fetch.js +100 -0
- package/src/index.d.ts +57 -0
- package/src/index.js +212 -0
- package/src/parse.d.ts +31 -0
- package/src/parse.js +116 -0
- package/src/verify.d.ts +24 -0
- package/src/verify.js +103 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file. The
|
|
4
|
+
format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
|
5
|
+
this project follows [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] — 2026-05-22
|
|
8
|
+
|
|
9
|
+
Initial release. Phase 1 of the [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum) evolution brief.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `verifyManifest(input)` — verify an attestation manifest object or JSON string. Returns the 3-state result envelope (`valid` / `invalid` / `error`).
|
|
13
|
+
- `verifyUrl(url, opts?)` — fetch and verify an attestation by URL. May additionally return `rotated` when the live well-known endpoint serves a different kid than the manifest declared.
|
|
14
|
+
- `parseManifest(input)` — typed-ish parser with byte-count sanity for ML-DSA-65 publicKey (1952 bytes) and signature (3309 bytes).
|
|
15
|
+
- `verifySignature(publicKey, message, signature)` — ML-DSA-65 (NIST FIPS 204) verification via `@noble/post-quantum`.
|
|
16
|
+
- `computeKid(publicKey)` — first 16 hex chars of SHA-256(rawPubkeyBytes). Matches the algorithm used by `kxco-post-quantum`'s `fingerprint()`.
|
|
17
|
+
- `getJsonBody(url, opts?)` — fetch helper with timeout (default 3000ms), max-byte cap (default 200KB), and SSRF-aware URL validation.
|
|
18
|
+
- Browser-safe implementation throughout — no `Buffer`, no `node:crypto`, no `process`. Uses `crypto.subtle` where available, falls back to `node:crypto` on Node 18.
|
|
19
|
+
- Live production fixtures captured in `fixtures/` for `chain.kxco.ai/wallet` and `www.target150.com`.
|
|
20
|
+
- Test suite: 50 tests, 97.5%+ line coverage, includes adversarial cases (signature tampering, kid mismatch, malformed JSON, byte-length attacks).
|
|
21
|
+
- Smoke script (`npm run smoke`) that exercises both production endpoints end-to-end.
|
|
22
|
+
|
|
23
|
+
### Known limitations (deliberately deferred to later phases)
|
|
24
|
+
- No KXCO-controlled key registry. Verification is a math claim only.
|
|
25
|
+
- No SLH-DSA-128s or hybrid envelopes — ML-DSA-65 only.
|
|
26
|
+
- No transparency log.
|
|
27
|
+
- The browser app at `verify.kxco.ai` is gated by CORS on the target site. Sites that don't serve `Access-Control-Allow-Origin: *` on their attestation endpoint require the "paste the JSON" fallback path.
|
|
28
|
+
|
|
29
|
+
### License
|
|
30
|
+
Apache 2.0. Independent of the (MIT-licensed) `kxco-post-quantum` signer.
|
|
31
|
+
|
|
32
|
+
[0.1.0]: https://github.com/JackKXCO/kxco-verify/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# kxco-verify
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/kxco-verify)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://verify.kxco.ai)
|
|
6
|
+
|
|
7
|
+
**Independent, browser-safe verifier for post-quantum signed deploy attestations** produced by sites using [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum).
|
|
8
|
+
|
|
9
|
+
Zero runtime dependencies beyond [`@noble/post-quantum`](https://github.com/paulmillr/noble-post-quantum). Apache 2.0. Works the same in Node 18+ and in any modern browser — the library never imports anything Node-specific. The public [`verify.kxco.ai`](https://verify.kxco.ai) web app runs this exact library entirely client-side; nothing is sent to a KXCO server.
|
|
10
|
+
|
|
11
|
+
> **Read this before you use the result.** A `"valid"` result means the signature math checks out against the public key the site itself declared, AND that key currently matches the live well-known endpoint. **It does not mean KXCO has vetted the site, its operator, or its content.** A self-signed attestation from a brand-new domain looks the same as one from a real institution — that's the point of cryptography, not a bug in the verifier. See [`docs/threat-model.md`](./docs/threat-model.md) for what is and isn't proven.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install kxco-verify
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Node ≥18. ESM only.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
### Verify a URL
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
import { verifyUrl } from 'kxco-verify'
|
|
29
|
+
|
|
30
|
+
const r = await verifyUrl('https://www.target150.com/api/attestation')
|
|
31
|
+
|
|
32
|
+
console.log(r.state) // 'valid' | 'rotated' | 'invalid' | 'error'
|
|
33
|
+
console.log(r.algorithm) // 'ML-DSA-65'
|
|
34
|
+
console.log(r.manifestKid) // '680f9af0bb44de3f'
|
|
35
|
+
console.log(r.deployment) // { git_commit: '...', env: 'production', ... }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Verify a manifest you already have
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
import { verifyManifest } from 'kxco-verify'
|
|
42
|
+
|
|
43
|
+
const body = await fetch('https://www.target150.com/api/attestation').then(r => r.text())
|
|
44
|
+
const r = await verifyManifest(body)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### CLI smoke against the production endpoints
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx kxco-verify-smoke
|
|
51
|
+
# or, if cloned locally:
|
|
52
|
+
npm run smoke
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## The 3-state result
|
|
58
|
+
|
|
59
|
+
This is the single most important thing to understand before consuming this library.
|
|
60
|
+
|
|
61
|
+
| `state` | What it means | What it does not mean |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `"valid"` | Signature mathematically checks out against the manifest-declared key, AND that key matches the live `pinAt` well-known endpoint. | That the site is trustworthy, that KXCO endorses it, or that its content is what it claims to be. |
|
|
64
|
+
| `"rotated"` | Signature checks out against the kid the manifest declared, but the well-known endpoint now serves a different kid. The site is mid key-rotation. Retry shortly. | That the site is compromised. |
|
|
65
|
+
| `"invalid"` | The signature does NOT verify under the manifest's declared key, OR the kid does not match SHA-256 of the published pubkey bytes. | Necessarily that the site is malicious — could also be a misconfigured signer or tampering in flight. |
|
|
66
|
+
| `"error"` | The verifier could not run (network failure, malformed JSON, unsupported algorithm). | That the signature is invalid. |
|
|
67
|
+
|
|
68
|
+
The full shape returned by `verifyUrl` / `verifyManifest` is documented in [`src/index.d.ts`](./src/index.d.ts).
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## What the library does NOT do (yet)
|
|
73
|
+
|
|
74
|
+
Tracked for later phases of [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum):
|
|
75
|
+
|
|
76
|
+
- **No KXCO-controlled key registry.** There is no mapping from `domain → approved kid`. Anyone can generate an ML-DSA-65 keypair in 30 seconds and publish a self-signed manifest; this library will mark it `"valid"`. That is intentional for this release — verification is a math claim, not an endorsement. Future phases will add a registry; the verifier will then surface registry-status alongside the math result.
|
|
77
|
+
- **No SLH-DSA-128s or hybrid envelopes.** ML-DSA-65 (NIST FIPS 204) only.
|
|
78
|
+
- **No transparency log.** Whether a given attestation has been publicly seen before is not tracked here.
|
|
79
|
+
|
|
80
|
+
If your threat model requires any of the above, do not call a `"valid"` result a "trust verdict" in your UI without your own additional checks.
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Browser usage
|
|
85
|
+
|
|
86
|
+
The library is browser-safe by construction — no `Buffer`, no `node:crypto`, no `process`. SHA-256 is taken from `crypto.subtle` where available (every browser, Node 20+) and falls back to `node:crypto` on Node 18.
|
|
87
|
+
|
|
88
|
+
Use a bundler (Vite, esbuild, Rollup, webpack) or load as ESM directly via an import map:
|
|
89
|
+
|
|
90
|
+
```html
|
|
91
|
+
<script type="importmap">
|
|
92
|
+
{
|
|
93
|
+
"imports": {
|
|
94
|
+
"@noble/post-quantum/ml-dsa": "/lib/@noble/post-quantum/ml-dsa.js",
|
|
95
|
+
"kxco-verify": "/lib/kxco-verify/src/index.js"
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
</script>
|
|
99
|
+
<script type="module">
|
|
100
|
+
import { verifyUrl } from 'kxco-verify'
|
|
101
|
+
const r = await verifyUrl('https://example.com/api/attestation')
|
|
102
|
+
console.log(r.state)
|
|
103
|
+
</script>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
This is exactly what [`verify.kxco.ai`](https://verify.kxco.ai) does.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Manifest shape
|
|
111
|
+
|
|
112
|
+
The verifier accepts the manifest shape produced by `kxco-post-quantum` since v1.0.x. The reference document is [`docs/verification.md`](./docs/verification.md). In brief:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"manifest": {
|
|
117
|
+
"site": "example.com",
|
|
118
|
+
"alg": "ML-DSA-65",
|
|
119
|
+
"spec": "NIST FIPS 204",
|
|
120
|
+
"kid": "<16 hex chars>",
|
|
121
|
+
"deployment": { "<opaque-site-defined-fields>": "..." },
|
|
122
|
+
"msgFormat": "<template describing what was signed>"
|
|
123
|
+
},
|
|
124
|
+
"signedMessage": "<the actual bytes that were signed, as a string>",
|
|
125
|
+
"signature": { "alg": "ML-DSA-65", "encoding": "hex", "value": "<6618 hex chars>" },
|
|
126
|
+
"publicKey": {
|
|
127
|
+
"alg": "ML-DSA-65", "encoding": "hex", "value": "<3904 hex chars>",
|
|
128
|
+
"kid": "<same as manifest.kid>",
|
|
129
|
+
"pinAt": "<relative URL of the live well-known pubkey endpoint>"
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Two live, real-world examples ship in [`fixtures/`](./fixtures):
|
|
135
|
+
- `wallet-attestation.json` — `https://chain.kxco.ai/wallet/api/.well-known/kxco-pq-attestation`
|
|
136
|
+
- `target150-attestation.json` — `https://www.target150.com/api/attestation`
|
|
137
|
+
|
|
138
|
+
The test suite verifies the math against both.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Stability + versioning
|
|
143
|
+
|
|
144
|
+
- **v0.1.x:** initial release. The 3-state result envelope is the public API; new states will be added only on a major bump. New optional fields on the result object are non-breaking.
|
|
145
|
+
- **No telemetry.** The library makes only the HTTP requests you ask it to (the attestation URL + the publisher-declared `pinAt`). No KXCO endpoint is contacted.
|
|
146
|
+
- **Reproducible builds + SLSA provenance.** Every published version of this package carries an npm provenance attestation showing the exact commit and GitHub Actions workflow that produced it. Verify with `npm view kxco-verify --json | jq .dist.attestations`.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
Apache 2.0 — see [LICENSE](./LICENSE).
|
|
153
|
+
|
|
154
|
+
This library is deliberately permissively licensed and architecturally independent of the (MIT-licensed) [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum) signer. The two never share code. That separation makes the verifier auditable in isolation: a change to the signer cannot trick the verifier into trusting an unverified key.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## See also
|
|
159
|
+
|
|
160
|
+
- [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum) — the production-tested signing library used by KnightsVault, KXCO Bank, target150, and others
|
|
161
|
+
- [`verify.kxco.ai`](https://verify.kxco.ai) — public web verifier (runs this library client-side)
|
|
162
|
+
- [`docs/verification.md`](./docs/verification.md) — full end-to-end verification flow
|
|
163
|
+
- [`docs/threat-model.md`](./docs/threat-model.md) — what this library does and does not protect against
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kxco-verify",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Independent, browser-safe verifier for KXCO post-quantum signed deploy attestations. Verifies ML-DSA-65 (NIST FIPS 204) signatures emitted by sites that use kxco-post-quantum. No trust delegation: this library tells you whether the signature math checks out and whether the manifest-declared key matches the live well-known endpoint — it does NOT vouch for any site.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"post-quantum",
|
|
7
|
+
"pqc",
|
|
8
|
+
"ml-dsa",
|
|
9
|
+
"dilithium",
|
|
10
|
+
"nist",
|
|
11
|
+
"fips-204",
|
|
12
|
+
"verification",
|
|
13
|
+
"attestation",
|
|
14
|
+
"verify",
|
|
15
|
+
"kxco-post-quantum"
|
|
16
|
+
],
|
|
17
|
+
"license": "Apache-2.0",
|
|
18
|
+
"author": "KXCO by Knightsbridge <hello@kxco.ai>",
|
|
19
|
+
"homepage": "https://verify.kxco.ai",
|
|
20
|
+
"funding": "https://kxco.ai",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/JackKXCO/kxco-verify.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/JackKXCO/kxco-verify/issues"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"main": "./src/index.js",
|
|
31
|
+
"types": "./src/index.d.ts",
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./src/index.d.ts",
|
|
35
|
+
"import": "./src/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./parse": {
|
|
38
|
+
"types": "./src/parse.d.ts",
|
|
39
|
+
"import": "./src/parse.js"
|
|
40
|
+
},
|
|
41
|
+
"./verify": {
|
|
42
|
+
"types": "./src/verify.d.ts",
|
|
43
|
+
"import": "./src/verify.js"
|
|
44
|
+
},
|
|
45
|
+
"./fetch": {
|
|
46
|
+
"types": "./src/fetch.d.ts",
|
|
47
|
+
"import": "./src/fetch.js"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"src",
|
|
52
|
+
"README.md",
|
|
53
|
+
"LICENSE",
|
|
54
|
+
"CHANGELOG.md"
|
|
55
|
+
],
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18"
|
|
58
|
+
},
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"@noble/post-quantum": "^0.2.1"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"test": "node --test --test-reporter=spec test/parse.test.js test/verify.test.js test/fetch.test.js test/index.test.js",
|
|
64
|
+
"test:cov": "node --test --experimental-test-coverage test/parse.test.js test/verify.test.js test/fetch.test.js test/index.test.js",
|
|
65
|
+
"smoke": "node scripts/smoke.js",
|
|
66
|
+
"lint": "node -c src/index.js && node -c src/parse.js && node -c src/verify.js && node -c src/fetch.js"
|
|
67
|
+
},
|
|
68
|
+
"publishConfig": {
|
|
69
|
+
"provenance": true,
|
|
70
|
+
"access": "public"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/fetch.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface FetchOk {
|
|
2
|
+
ok: true
|
|
3
|
+
url: string
|
|
4
|
+
status: number
|
|
5
|
+
body: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface FetchErr {
|
|
9
|
+
ok: false
|
|
10
|
+
error: {
|
|
11
|
+
kind: 'fetch'
|
|
12
|
+
code: 'no_fetch' | 'invalid_url' | 'timeout' | 'network' | 'http_status' | 'too_large' | 'read'
|
|
13
|
+
message: string
|
|
14
|
+
url?: string
|
|
15
|
+
status?: number
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GetJsonBodyOpts {
|
|
20
|
+
timeoutMs?: number
|
|
21
|
+
maxBytes?: number
|
|
22
|
+
fetchImpl?: typeof fetch
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getJsonBody(
|
|
26
|
+
url: string,
|
|
27
|
+
opts?: GetJsonBodyOpts,
|
|
28
|
+
): Promise<FetchOk | FetchErr>
|
package/src/fetch.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Network fetching. Browser-safe (uses global `fetch`), with a hard timeout
|
|
2
|
+
// per request via AbortController so a slow target site cannot hang the
|
|
3
|
+
// verifier. Returns either { ok: true, body, url, status } or
|
|
4
|
+
// { ok: false, error: { kind: 'fetch', code, message } }.
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT_MS = 3000
|
|
7
|
+
const DEFAULT_MAX_BYTES = 200_000 // attestation manifests are ~11KB; cap at 200KB
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} FetchOk
|
|
11
|
+
* @property {true} ok
|
|
12
|
+
* @property {string} url
|
|
13
|
+
* @property {number} status
|
|
14
|
+
* @property {string} body
|
|
15
|
+
*
|
|
16
|
+
* @typedef {Object} FetchErr
|
|
17
|
+
* @property {false} ok
|
|
18
|
+
* @property {{ kind: 'fetch', code: string, message: string, url?: string, status?: number }} error
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* GET a URL with a timeout, returning the body as a UTF-8 string. Caps the
|
|
23
|
+
* response at maxBytes to defend against memory blow-ups from a hostile
|
|
24
|
+
* target. Does NOT throw — always returns a tagged result.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} url
|
|
27
|
+
* @param {{ timeoutMs?: number, maxBytes?: number, fetchImpl?: typeof fetch }} [opts]
|
|
28
|
+
* @returns {Promise<FetchOk | FetchErr>}
|
|
29
|
+
*/
|
|
30
|
+
export async function getJsonBody(url, opts = {}) {
|
|
31
|
+
const timeoutMs = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : DEFAULT_TIMEOUT_MS
|
|
32
|
+
const maxBytes = typeof opts.maxBytes === 'number' ? opts.maxBytes : DEFAULT_MAX_BYTES
|
|
33
|
+
const f = opts.fetchImpl || globalThis.fetch
|
|
34
|
+
|
|
35
|
+
if (typeof f !== 'function') {
|
|
36
|
+
return errFetch_('no_fetch', 'global fetch() not available; pass opts.fetchImpl', { url })
|
|
37
|
+
}
|
|
38
|
+
if (typeof url !== 'string' || (!url.startsWith('http://') && !url.startsWith('https://'))) {
|
|
39
|
+
return errFetch_('invalid_url', 'url must be an absolute http(s) URL', { url })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ctrl = new AbortController()
|
|
43
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs)
|
|
44
|
+
let res
|
|
45
|
+
try {
|
|
46
|
+
res = await f(url, { signal: ctrl.signal, redirect: 'follow', headers: { 'Accept': 'application/json' } })
|
|
47
|
+
} catch (err) {
|
|
48
|
+
clearTimeout(timer)
|
|
49
|
+
const code = err && err.name === 'AbortError' ? 'timeout' : 'network'
|
|
50
|
+
return errFetch_(code, code === 'timeout' ? `fetch timed out after ${timeoutMs}ms` : `network error: ${err.message}`, { url })
|
|
51
|
+
}
|
|
52
|
+
clearTimeout(timer)
|
|
53
|
+
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
return errFetch_('http_status', `HTTP ${res.status}`, { url, status: res.status })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Read with a byte cap to prevent OOM on hostile responses.
|
|
59
|
+
let body
|
|
60
|
+
try {
|
|
61
|
+
body = await readWithCap_(res, maxBytes)
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err && err.code === 'too_large') {
|
|
64
|
+
return errFetch_('too_large', `response exceeded ${maxBytes} bytes`, { url, status: res.status })
|
|
65
|
+
}
|
|
66
|
+
return errFetch_('read', `failed to read response body: ${err.message}`, { url, status: res.status })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { ok: true, url, status: res.status, body }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function readWithCap_(res, maxBytes) {
|
|
73
|
+
if (!res.body || typeof res.body.getReader !== 'function') {
|
|
74
|
+
// Fallback for environments without streams: read the whole text, check size.
|
|
75
|
+
const text = await res.text()
|
|
76
|
+
if (text.length > maxBytes * 4) { const e = new Error('too large'); e.code = 'too_large'; throw e }
|
|
77
|
+
return text
|
|
78
|
+
}
|
|
79
|
+
const reader = res.body.getReader()
|
|
80
|
+
const chunks = []
|
|
81
|
+
let total = 0
|
|
82
|
+
while (true) {
|
|
83
|
+
const { done, value } = await reader.read()
|
|
84
|
+
if (done) break
|
|
85
|
+
total += value.length
|
|
86
|
+
if (total > maxBytes) {
|
|
87
|
+
try { await reader.cancel() } catch {}
|
|
88
|
+
const e = new Error('too large'); e.code = 'too_large'; throw e
|
|
89
|
+
}
|
|
90
|
+
chunks.push(value)
|
|
91
|
+
}
|
|
92
|
+
const merged = new Uint8Array(total)
|
|
93
|
+
let off = 0
|
|
94
|
+
for (const c of chunks) { merged.set(c, off); off += c.length }
|
|
95
|
+
return new TextDecoder('utf-8').decode(merged)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function errFetch_(code, message, extra = {}) {
|
|
99
|
+
return { ok: false, error: { kind: 'fetch', code, message, ...extra } }
|
|
100
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type VerifyState = 'valid' | 'rotated' | 'invalid' | 'error'
|
|
2
|
+
|
|
3
|
+
export interface VerifyResultError {
|
|
4
|
+
kind: 'parse' | 'fetch' | 'signature' | 'consistency' | 'rotation'
|
|
5
|
+
code: string
|
|
6
|
+
message: string
|
|
7
|
+
/** true when the error is non-fatal (math succeeded but a soft check failed). */
|
|
8
|
+
soft?: boolean
|
|
9
|
+
[k: string]: unknown
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface VerifyResult {
|
|
13
|
+
state: VerifyState
|
|
14
|
+
algorithm?: 'ML-DSA-65'
|
|
15
|
+
/** kid as declared inside the manifest itself */
|
|
16
|
+
manifestKid?: string
|
|
17
|
+
/** kid currently served at the live well-known pubkey endpoint (when fetched) */
|
|
18
|
+
livePubkeyKid?: string
|
|
19
|
+
/** site identifier as declared by the manifest */
|
|
20
|
+
site?: string
|
|
21
|
+
/** opaque deployment metadata from the manifest */
|
|
22
|
+
deployment?: Record<string, unknown>
|
|
23
|
+
/** full parsed manifest JSON, for UI display */
|
|
24
|
+
manifestRaw?: Record<string, unknown>
|
|
25
|
+
/** present when state is "error", "invalid", or "rotated" */
|
|
26
|
+
error?: VerifyResultError
|
|
27
|
+
attestationUrl?: string
|
|
28
|
+
/** URL of the live well-known pubkey endpoint (only present when fetched) */
|
|
29
|
+
pubkeyUrl?: string
|
|
30
|
+
/** Date.now() at the moment verification finished */
|
|
31
|
+
verifiedAtMs?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VerifyUrlOpts {
|
|
35
|
+
timeoutMs?: number
|
|
36
|
+
maxBytes?: number
|
|
37
|
+
fetchImpl?: typeof fetch
|
|
38
|
+
/** When true, skip fetching the live well-known pubkey (no rotation detection). */
|
|
39
|
+
skipLivePubkey?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Verify an attestation manifest you already have in hand.
|
|
44
|
+
* Result is "valid" / "invalid" / "error" — never "rotated" (no live fetch).
|
|
45
|
+
*/
|
|
46
|
+
export function verifyManifest(manifestBody: string | object): Promise<VerifyResult>
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Verify an attestation by URL. May return "rotated" if the live well-known
|
|
50
|
+
* pubkey endpoint serves a different kid than the manifest declared.
|
|
51
|
+
*/
|
|
52
|
+
export function verifyUrl(attestationUrl: string, opts?: VerifyUrlOpts): Promise<VerifyResult>
|
|
53
|
+
|
|
54
|
+
// Re-exports.
|
|
55
|
+
export { parseManifest, ParseResult, ParsedManifest, ParseError } from './parse.js'
|
|
56
|
+
export { verifySignature, computeKid, hexToBytes, bytesToHex, hexEquals } from './verify.js'
|
|
57
|
+
export { getJsonBody, FetchOk, FetchErr, GetJsonBodyOpts } from './fetch.js'
|
package/src/index.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// kxco-verify — public entry point.
|
|
2
|
+
//
|
|
3
|
+
// What this library is for:
|
|
4
|
+
// Given a URL (or an attestation manifest you already have), determine
|
|
5
|
+
// whether a site's post-quantum deploy attestation is mathematically valid.
|
|
6
|
+
//
|
|
7
|
+
// What it deliberately does NOT do:
|
|
8
|
+
// - Endorse any site. A "valid" result means "the site signed its own
|
|
9
|
+
// manifest with a key it published" — nothing about who the site is or
|
|
10
|
+
// whether its content is trustworthy.
|
|
11
|
+
// - Maintain a registry of approved (domain, kid) pairs. That is a future
|
|
12
|
+
// phase of kxco-post-quantum / verify.kxco.ai and not in this library.
|
|
13
|
+
// - Speak any algorithm other than ML-DSA-65 with hex encoding in this
|
|
14
|
+
// release. SLH-DSA-128s and hybrid envelopes are deferred.
|
|
15
|
+
//
|
|
16
|
+
// The result envelope is intentionally three-valued so the UI can distinguish
|
|
17
|
+
// signature failure from in-flight key rotation:
|
|
18
|
+
//
|
|
19
|
+
// "valid" — signature checks against the manifest's declared kid AND
|
|
20
|
+
// that kid matches the live well-known pubkey endpoint
|
|
21
|
+
// "rotated" — signature checks against the manifest's declared kid, BUT
|
|
22
|
+
// the live well-known endpoint now serves a different kid
|
|
23
|
+
// (interpret as: site is in the middle of a key rotation;
|
|
24
|
+
// ask the user to retry shortly)
|
|
25
|
+
// "invalid" — signature does not check against the manifest-declared key
|
|
26
|
+
// (interpret as: signature is forged, manifest was tampered
|
|
27
|
+
// with, or the publisher's signing pipeline is broken)
|
|
28
|
+
//
|
|
29
|
+
// All other failure modes (network, malformed JSON, unsupported algorithm)
|
|
30
|
+
// surface as { state: "error", error: {...} }.
|
|
31
|
+
|
|
32
|
+
import { parseManifest } from './parse.js'
|
|
33
|
+
import { verifySignature,
|
|
34
|
+
computeKid,
|
|
35
|
+
hexEquals } from './verify.js'
|
|
36
|
+
import { getJsonBody } from './fetch.js'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {'valid'|'rotated'|'invalid'|'error'} VerifyState
|
|
40
|
+
*
|
|
41
|
+
* @typedef {Object} VerifyResult
|
|
42
|
+
* @property {VerifyState} state
|
|
43
|
+
* @property {string=} algorithm — e.g. "ML-DSA-65"
|
|
44
|
+
* @property {string=} manifestKid — kid the manifest declared
|
|
45
|
+
* @property {string=} livePubkeyKid — kid currently served at the well-known endpoint (only present when fetched)
|
|
46
|
+
* @property {string=} site — site identifier as declared by the manifest
|
|
47
|
+
* @property {object=} deployment — opaque deployment metadata from the manifest
|
|
48
|
+
* @property {object=} manifestRaw — full parsed manifest JSON (for UI display)
|
|
49
|
+
* @property {{ kind: string, code: string, message: string, [k: string]: any }=} error
|
|
50
|
+
* @property {string=} attestationUrl
|
|
51
|
+
* @property {string=} pubkeyUrl
|
|
52
|
+
* @property {number=} verifiedAtMs — Date.now() at the moment verification finished
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Verify an attestation manifest you already have in hand (e.g. paste from
|
|
57
|
+
* the user, or already fetched). Does NOT contact the live well-known
|
|
58
|
+
* endpoint — so the result is at best "valid" or "invalid" but never "rotated".
|
|
59
|
+
*
|
|
60
|
+
* @param {string|object} manifestBody — raw JSON body or parsed object
|
|
61
|
+
* @returns {Promise<VerifyResult>}
|
|
62
|
+
*/
|
|
63
|
+
export async function verifyManifest(manifestBody) {
|
|
64
|
+
const parsed = parseManifest(manifestBody)
|
|
65
|
+
if (!parsed.ok) return { state: 'error', error: parsed.error, verifiedAtMs: Date.now() }
|
|
66
|
+
const m = parsed.manifest
|
|
67
|
+
|
|
68
|
+
// The publisher's own consistency check: the kid embedded inside the
|
|
69
|
+
// publicKey block must match what manifest.kid says, AND must match the
|
|
70
|
+
// SHA-256 fingerprint of the pubkey bytes. If either disagrees, the
|
|
71
|
+
// manifest is internally inconsistent — treat as invalid before we even
|
|
72
|
+
// run the slow signature math.
|
|
73
|
+
const recomputedKid = await computeKid(m.publicKeyHex)
|
|
74
|
+
if (!hexEquals(recomputedKid, m.kid) || !hexEquals(recomputedKid, m.publicKeyKid)) {
|
|
75
|
+
return {
|
|
76
|
+
state: 'invalid',
|
|
77
|
+
algorithm: m.alg,
|
|
78
|
+
manifestKid: m.kid,
|
|
79
|
+
site: m.site,
|
|
80
|
+
manifestRaw: m.raw,
|
|
81
|
+
error: {
|
|
82
|
+
kind: 'consistency',
|
|
83
|
+
code: 'kid_mismatch_internal',
|
|
84
|
+
message: `manifest.kid (${m.kid}) and/or publicKey.kid (${m.publicKeyKid}) disagree with SHA-256(publicKey) (${recomputedKid})`,
|
|
85
|
+
},
|
|
86
|
+
verifiedAtMs: Date.now(),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const sigValid = verifySignature(m.publicKeyHex, m.signedMessage, m.signatureHex)
|
|
91
|
+
return {
|
|
92
|
+
state: sigValid ? 'valid' : 'invalid',
|
|
93
|
+
algorithm: m.alg,
|
|
94
|
+
manifestKid: m.kid,
|
|
95
|
+
site: m.site,
|
|
96
|
+
deployment: m.deployment,
|
|
97
|
+
manifestRaw: m.raw,
|
|
98
|
+
error: sigValid ? undefined : {
|
|
99
|
+
kind: 'signature',
|
|
100
|
+
code: 'invalid_signature',
|
|
101
|
+
message: 'signature does not verify under the manifest-declared public key',
|
|
102
|
+
},
|
|
103
|
+
verifiedAtMs: Date.now(),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Verify an attestation by URL.
|
|
109
|
+
*
|
|
110
|
+
* 1. Fetch the attestation URL.
|
|
111
|
+
* 2. Parse + math-verify it (as verifyManifest does).
|
|
112
|
+
* 3. If the manifest declared `publicKey.pinAt`, also fetch that endpoint
|
|
113
|
+
* and compare its kid to the manifest's. A mismatch downgrades a "valid"
|
|
114
|
+
* result to "rotated" — the signature checks against the kid the
|
|
115
|
+
* manifest declared, but the live endpoint now serves a different kid.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} attestationUrl
|
|
118
|
+
* @param {{ timeoutMs?: number, maxBytes?: number, fetchImpl?: typeof fetch, skipLivePubkey?: boolean }} [opts]
|
|
119
|
+
* @returns {Promise<VerifyResult>}
|
|
120
|
+
*/
|
|
121
|
+
export async function verifyUrl(attestationUrl, opts = {}) {
|
|
122
|
+
const t0 = Date.now()
|
|
123
|
+
const fetched = await getJsonBody(attestationUrl, opts)
|
|
124
|
+
if (!fetched.ok) {
|
|
125
|
+
return { state: 'error', error: fetched.error, attestationUrl, verifiedAtMs: Date.now() }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = await verifyManifest(fetched.body)
|
|
129
|
+
result.attestationUrl = attestationUrl
|
|
130
|
+
|
|
131
|
+
// Math failed already; no benefit in fetching the live pubkey.
|
|
132
|
+
if (result.state !== 'valid' || opts.skipLivePubkey) {
|
|
133
|
+
result.verifiedAtMs = Date.now()
|
|
134
|
+
return result
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Resolve pinAt against the attestation URL's origin.
|
|
138
|
+
const pinAt = result.manifestRaw?.publicKey?.pinAt
|
|
139
|
+
if (typeof pinAt !== 'string' || !pinAt) {
|
|
140
|
+
// No pinAt → publisher didn't tell us where the live pubkey is, so we
|
|
141
|
+
// cannot detect rotation. Return valid as-is and let the UI explain.
|
|
142
|
+
result.verifiedAtMs = Date.now()
|
|
143
|
+
return result
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let pubkeyUrl
|
|
147
|
+
try {
|
|
148
|
+
pubkeyUrl = new URL(pinAt, attestationUrl).toString()
|
|
149
|
+
} catch (err) {
|
|
150
|
+
result.error = { kind: 'consistency', code: 'invalid_pinAt', message: `manifest.publicKey.pinAt is not a resolvable URL: ${err.message}` }
|
|
151
|
+
result.state = 'invalid'
|
|
152
|
+
result.verifiedAtMs = Date.now()
|
|
153
|
+
return result
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const livePk = await getJsonBody(pubkeyUrl, opts)
|
|
157
|
+
if (!livePk.ok) {
|
|
158
|
+
// Couldn't fetch the live pubkey. Don't downgrade — the math succeeded.
|
|
159
|
+
// Tell the UI we couldn't confirm rotation status.
|
|
160
|
+
result.pubkeyUrl = pubkeyUrl
|
|
161
|
+
result.error = { kind: 'fetch', code: livePk.error.code, message: `could not fetch live pubkey at ${pubkeyUrl}: ${livePk.error.message}`, soft: true }
|
|
162
|
+
result.verifiedAtMs = Date.now()
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let liveJson
|
|
167
|
+
try { liveJson = JSON.parse(livePk.body) }
|
|
168
|
+
catch (err) {
|
|
169
|
+
result.pubkeyUrl = pubkeyUrl
|
|
170
|
+
result.error = { kind: 'parse', code: 'invalid_live_pubkey_json', message: `live pubkey body is not JSON: ${err.message}`, soft: true }
|
|
171
|
+
result.verifiedAtMs = Date.now()
|
|
172
|
+
return result
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Two ways the publisher may serve the kid: explicit "kid" field, or we
|
|
176
|
+
// recompute from the publicKey hex. Prefer recompute as the source of truth.
|
|
177
|
+
const livePubkeyHex = typeof liveJson.publicKey === 'string'
|
|
178
|
+
? liveJson.publicKey
|
|
179
|
+
: (typeof liveJson?.value === 'string' ? liveJson.value : null)
|
|
180
|
+
if (!livePubkeyHex) {
|
|
181
|
+
result.pubkeyUrl = pubkeyUrl
|
|
182
|
+
result.error = { kind: 'consistency', code: 'live_pubkey_missing', message: 'live well-known endpoint did not return a publicKey field', soft: true }
|
|
183
|
+
result.verifiedAtMs = Date.now()
|
|
184
|
+
return result
|
|
185
|
+
}
|
|
186
|
+
const liveKid = await computeKid(livePubkeyHex)
|
|
187
|
+
result.pubkeyUrl = pubkeyUrl
|
|
188
|
+
result.livePubkeyKid = liveKid
|
|
189
|
+
|
|
190
|
+
if (!hexEquals(liveKid, result.manifestKid)) {
|
|
191
|
+
// Signature checked against manifest.kid but the well-known now serves
|
|
192
|
+
// a different kid. This is the rotation signal.
|
|
193
|
+
result.state = 'rotated'
|
|
194
|
+
result.error = {
|
|
195
|
+
kind: 'rotation',
|
|
196
|
+
code: 'live_kid_mismatch',
|
|
197
|
+
message: `signature is valid for kid ${result.manifestKid}, but the live well-known endpoint now serves kid ${liveKid}. The site is likely mid key-rotation; retry shortly.`,
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
result.verifiedAtMs = Date.now()
|
|
202
|
+
return result
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Re-export the lower-level helpers for users who want to compose.
|
|
206
|
+
export { parseManifest } from './parse.js'
|
|
207
|
+
export { verifySignature,
|
|
208
|
+
computeKid,
|
|
209
|
+
hexToBytes,
|
|
210
|
+
bytesToHex,
|
|
211
|
+
hexEquals } from './verify.js'
|
|
212
|
+
export { getJsonBody } from './fetch.js'
|
package/src/parse.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface ParsedManifest {
|
|
2
|
+
site: string
|
|
3
|
+
alg: 'ML-DSA-65'
|
|
4
|
+
kid: string
|
|
5
|
+
signedMessage: string
|
|
6
|
+
signatureHex: string
|
|
7
|
+
publicKeyHex: string
|
|
8
|
+
publicKeyKid: string
|
|
9
|
+
pinAt?: string
|
|
10
|
+
deployment?: Record<string, unknown>
|
|
11
|
+
raw: Record<string, unknown>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ParseError {
|
|
15
|
+
kind: 'parse'
|
|
16
|
+
code:
|
|
17
|
+
| 'invalid_json'
|
|
18
|
+
| 'invalid_input'
|
|
19
|
+
| 'missing_field'
|
|
20
|
+
| 'invalid_field'
|
|
21
|
+
| 'unsupported_algorithm'
|
|
22
|
+
| 'unsupported_encoding'
|
|
23
|
+
message: string
|
|
24
|
+
field?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ParseResult =
|
|
28
|
+
| { ok: true; manifest: ParsedManifest }
|
|
29
|
+
| { ok: false; error: ParseError }
|
|
30
|
+
|
|
31
|
+
export function parseManifest(input: string | object): ParseResult
|
package/src/parse.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Manifest parsing.
|
|
2
|
+
//
|
|
3
|
+
// Takes the raw JSON body served by an `/api/attestation`-style endpoint and
|
|
4
|
+
// returns a normalised, typed-ish shape that the verifier can work with —
|
|
5
|
+
// or a structured error if the shape is wrong.
|
|
6
|
+
//
|
|
7
|
+
// The reference manifest shape is what target150.com/api/attestation and
|
|
8
|
+
// chain.kxco.ai/wallet/api/.well-known/kxco-pq-attestation emit today:
|
|
9
|
+
//
|
|
10
|
+
// {
|
|
11
|
+
// "manifest": {
|
|
12
|
+
// "site": "example.com",
|
|
13
|
+
// "alg": "ML-DSA-65",
|
|
14
|
+
// "spec": "NIST FIPS 204",
|
|
15
|
+
// "kid": "<16-hex-char fingerprint>",
|
|
16
|
+
// "deployment": { ...site-defined fields... },
|
|
17
|
+
// "msgFormat": "<template describing what was signed>"
|
|
18
|
+
// },
|
|
19
|
+
// "signedMessage": "<the actual bytes that were signed, as a string>",
|
|
20
|
+
// "signature": { "alg": "ML-DSA-65", "encoding": "hex", "value": "<6618 hex chars>" },
|
|
21
|
+
// "publicKey": { "alg": "ML-DSA-65", "encoding": "hex", "value": "<3904 hex chars>", "kid": "<same as manifest.kid>", "pinAt": "<relative URL>" }
|
|
22
|
+
// }
|
|
23
|
+
//
|
|
24
|
+
// We accept only ML-DSA-65 with hex encoding in this version. SLH-DSA-128s
|
|
25
|
+
// and Ed25519+ML-DSA hybrid envelopes are reserved for later.
|
|
26
|
+
|
|
27
|
+
const HEX_RE = /^[0-9a-f]+$/i
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} ParsedManifest
|
|
31
|
+
* @property {string} site
|
|
32
|
+
* @property {string} alg — must be "ML-DSA-65" in this version
|
|
33
|
+
* @property {string} kid
|
|
34
|
+
* @property {string} signedMessage — the exact bytes the signature covers
|
|
35
|
+
* @property {string} signatureHex
|
|
36
|
+
* @property {string} publicKeyHex
|
|
37
|
+
* @property {string} publicKeyKid — kid as declared inside the publicKey block
|
|
38
|
+
* @property {string=} pinAt — relative path where the publisher recommends re-fetching the pubkey
|
|
39
|
+
* @property {object=} deployment — site-defined metadata, opaque to us
|
|
40
|
+
* @property {object} raw — the parsed JSON in its entirety, for the UI to display
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} ParseError
|
|
45
|
+
* @property {'parse'} kind
|
|
46
|
+
* @property {string} code — short stable identifier (e.g. "missing_field")
|
|
47
|
+
* @property {string} message
|
|
48
|
+
* @property {string=} field
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Parse an attestation manifest from a JSON body. Returns either
|
|
53
|
+
* { ok: true, manifest } or { ok: false, error }.
|
|
54
|
+
*
|
|
55
|
+
* @param {string|object} input — raw JSON string OR an already-parsed object
|
|
56
|
+
* @returns {{ ok: true, manifest: ParsedManifest } | { ok: false, error: ParseError }}
|
|
57
|
+
*/
|
|
58
|
+
export function parseManifest(input) {
|
|
59
|
+
let raw
|
|
60
|
+
if (typeof input === 'string') {
|
|
61
|
+
try { raw = JSON.parse(input) }
|
|
62
|
+
catch (err) { return err_('invalid_json', `body is not valid JSON: ${err.message}`) }
|
|
63
|
+
} else if (input && typeof input === 'object') {
|
|
64
|
+
raw = input
|
|
65
|
+
} else {
|
|
66
|
+
return err_('invalid_input', 'input must be a JSON string or an object')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const m = raw.manifest
|
|
70
|
+
const s = raw.signature
|
|
71
|
+
const pk = raw.publicKey
|
|
72
|
+
|
|
73
|
+
if (!m || typeof m !== 'object') return err_('missing_field', 'top-level "manifest" is missing or not an object', 'manifest')
|
|
74
|
+
if (!s || typeof s !== 'object') return err_('missing_field', 'top-level "signature" is missing or not an object', 'signature')
|
|
75
|
+
if (!pk || typeof pk !== 'object') return err_('missing_field', 'top-level "publicKey" is missing or not an object', 'publicKey')
|
|
76
|
+
|
|
77
|
+
// We only verify ML-DSA-65 with hex-encoded signature + pubkey in this
|
|
78
|
+
// release. Anything else is a feature we deferred to a later version.
|
|
79
|
+
if (m.alg !== 'ML-DSA-65') return err_('unsupported_algorithm', `manifest.alg must be "ML-DSA-65" (got ${JSON.stringify(m.alg)})`, 'manifest.alg')
|
|
80
|
+
if (s.alg !== 'ML-DSA-65') return err_('unsupported_algorithm', `signature.alg must be "ML-DSA-65" (got ${JSON.stringify(s.alg)})`, 'signature.alg')
|
|
81
|
+
if (pk.alg !== 'ML-DSA-65') return err_('unsupported_algorithm', `publicKey.alg must be "ML-DSA-65" (got ${JSON.stringify(pk.alg)})`, 'publicKey.alg')
|
|
82
|
+
if (s.encoding && s.encoding !== 'hex') return err_('unsupported_encoding', `signature.encoding must be "hex" (got ${JSON.stringify(s.encoding)})`, 'signature.encoding')
|
|
83
|
+
if (pk.encoding && pk.encoding !== 'hex') return err_('unsupported_encoding', `publicKey.encoding must be "hex" (got ${JSON.stringify(pk.encoding)})`, 'publicKey.encoding')
|
|
84
|
+
|
|
85
|
+
if (typeof m.kid !== 'string' || !HEX_RE.test(m.kid)) return err_('invalid_field', 'manifest.kid must be a hex string', 'manifest.kid')
|
|
86
|
+
if (typeof m.site !== 'string' || !m.site.length) return err_('invalid_field', 'manifest.site must be a non-empty string', 'manifest.site')
|
|
87
|
+
if (typeof raw.signedMessage !== 'string') return err_('missing_field', 'top-level "signedMessage" must be a string', 'signedMessage')
|
|
88
|
+
if (typeof s.value !== 'string' || !HEX_RE.test(s.value)) return err_('invalid_field', 'signature.value must be a hex string', 'signature.value')
|
|
89
|
+
if (typeof pk.value !== 'string' || !HEX_RE.test(pk.value)) return err_('invalid_field', 'publicKey.value must be a hex string', 'publicKey.value')
|
|
90
|
+
if (typeof pk.kid !== 'string' || !HEX_RE.test(pk.kid)) return err_('invalid_field', 'publicKey.kid must be a hex string', 'publicKey.kid')
|
|
91
|
+
|
|
92
|
+
// ML-DSA-65 size sanity. Don't trust the message saying "ML-DSA-65" —
|
|
93
|
+
// verify the byte counts match the spec. Catches malformed payloads early.
|
|
94
|
+
if (pk.value.length !== 3904) return err_('invalid_field', `publicKey.value must be 1952 bytes (3904 hex chars); got ${pk.value.length / 2}`, 'publicKey.value')
|
|
95
|
+
if (s.value.length !== 6618) return err_('invalid_field', `signature.value must be 3309 bytes (6618 hex chars); got ${s.value.length / 2}`, 'signature.value')
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
ok: true,
|
|
99
|
+
manifest: {
|
|
100
|
+
site: m.site,
|
|
101
|
+
alg: m.alg,
|
|
102
|
+
kid: m.kid.toLowerCase(),
|
|
103
|
+
signedMessage: raw.signedMessage,
|
|
104
|
+
signatureHex: s.value.toLowerCase(),
|
|
105
|
+
publicKeyHex: pk.value.toLowerCase(),
|
|
106
|
+
publicKeyKid: pk.kid.toLowerCase(),
|
|
107
|
+
pinAt: typeof pk.pinAt === 'string' ? pk.pinAt : undefined,
|
|
108
|
+
deployment: m.deployment && typeof m.deployment === 'object' ? m.deployment : undefined,
|
|
109
|
+
raw,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function err_(code, message, field) {
|
|
115
|
+
return { ok: false, error: { kind: 'parse', code, message, field } }
|
|
116
|
+
}
|
package/src/verify.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Hex string → Uint8Array. Throws on malformed input. */
|
|
2
|
+
export function hexToBytes(hex: string): Uint8Array
|
|
3
|
+
|
|
4
|
+
/** Uint8Array → hex string (lowercase). */
|
|
5
|
+
export function bytesToHex(bytes: Uint8Array): string
|
|
6
|
+
|
|
7
|
+
/** UTF-8 string → Uint8Array. */
|
|
8
|
+
export function utf8(s: string): Uint8Array
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Compute the kxco kid (key identifier) of a public key —
|
|
12
|
+
* first 16 hex chars of SHA-256(rawBytes).
|
|
13
|
+
*/
|
|
14
|
+
export function computeKid(publicKey: Uint8Array | string): Promise<string>
|
|
15
|
+
|
|
16
|
+
/** Verify an ML-DSA-65 signature. */
|
|
17
|
+
export function verifySignature(
|
|
18
|
+
publicKey: Uint8Array | string,
|
|
19
|
+
message: Uint8Array | string,
|
|
20
|
+
signature: Uint8Array | string,
|
|
21
|
+
): boolean
|
|
22
|
+
|
|
23
|
+
/** Length-equal hex comparison. */
|
|
24
|
+
export function hexEquals(a: string, b: string): boolean
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Signature math + kid math. Pure functions. No network, no I/O.
|
|
2
|
+
//
|
|
3
|
+
// We call @noble/post-quantum directly with Uint8Array so the same code runs
|
|
4
|
+
// in Node 18+ and in any modern browser without a polyfill. SHA-256 is taken
|
|
5
|
+
// from the Web Crypto API where available (browser, Node 20+), fallback to
|
|
6
|
+
// node:crypto's createHash where SubtleCrypto.digest is not present.
|
|
7
|
+
|
|
8
|
+
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Hex string → Uint8Array. Throws on malformed input.
|
|
12
|
+
* @param {string} hex
|
|
13
|
+
* @returns {Uint8Array}
|
|
14
|
+
*/
|
|
15
|
+
export function hexToBytes(hex) {
|
|
16
|
+
if (typeof hex !== 'string' || hex.length % 2 !== 0) {
|
|
17
|
+
throw new TypeError('hex input must be a string of even length')
|
|
18
|
+
}
|
|
19
|
+
const out = new Uint8Array(hex.length / 2)
|
|
20
|
+
for (let i = 0; i < out.length; i++) {
|
|
21
|
+
const byte = parseInt(hex.substr(i * 2, 2), 16)
|
|
22
|
+
if (Number.isNaN(byte)) throw new TypeError(`malformed hex at offset ${i * 2}`)
|
|
23
|
+
out[i] = byte
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Uint8Array → hex string (lowercase).
|
|
30
|
+
* @param {Uint8Array} bytes
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function bytesToHex(bytes) {
|
|
34
|
+
let out = ''
|
|
35
|
+
for (let i = 0; i < bytes.length; i++) out += bytes[i].toString(16).padStart(2, '0')
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* UTF-8 string → Uint8Array.
|
|
41
|
+
* @param {string} s
|
|
42
|
+
* @returns {Uint8Array}
|
|
43
|
+
*/
|
|
44
|
+
export function utf8(s) {
|
|
45
|
+
return new TextEncoder().encode(s)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute the kxco kid (key identifier) of a public key — first 16 hex chars
|
|
50
|
+
* of SHA-256(rawBytes). Matches the algorithm in kxco-post-quantum's
|
|
51
|
+
* `fingerprint()`. Async because SubtleCrypto.digest is async.
|
|
52
|
+
*
|
|
53
|
+
* @param {Uint8Array|string} publicKey — raw bytes or hex string
|
|
54
|
+
* @returns {Promise<string>} 16-char lowercase hex
|
|
55
|
+
*/
|
|
56
|
+
export async function computeKid(publicKey) {
|
|
57
|
+
const bytes = typeof publicKey === 'string' ? hexToBytes(publicKey) : publicKey
|
|
58
|
+
const subtle = globalThis.crypto && globalThis.crypto.subtle
|
|
59
|
+
let hashBytes
|
|
60
|
+
if (subtle && typeof subtle.digest === 'function') {
|
|
61
|
+
const ab = await subtle.digest('SHA-256', bytes)
|
|
62
|
+
hashBytes = new Uint8Array(ab)
|
|
63
|
+
} else {
|
|
64
|
+
// Node fallback. Only reached on Node <20 without globalThis.crypto.
|
|
65
|
+
const { createHash } = await import('node:crypto')
|
|
66
|
+
hashBytes = createHash('sha256').update(bytes).digest()
|
|
67
|
+
}
|
|
68
|
+
return bytesToHex(hashBytes.subarray(0, 8))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Verify an ML-DSA-65 signature.
|
|
73
|
+
* @param {string|Uint8Array} publicKey — 1952 bytes (3904 hex chars)
|
|
74
|
+
* @param {string|Uint8Array} message — string (utf8'd) or raw bytes
|
|
75
|
+
* @param {string|Uint8Array} signature — 3309 bytes (6618 hex chars)
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
export function verifySignature(publicKey, message, signature) {
|
|
79
|
+
const pk = typeof publicKey === 'string' ? hexToBytes(publicKey) : publicKey
|
|
80
|
+
const sig = typeof signature === 'string' ? hexToBytes(signature) : signature
|
|
81
|
+
const msg = typeof message === 'string' ? utf8(message) : message
|
|
82
|
+
// @noble/post-quantum signature: (publicKey, message, signature). Matches
|
|
83
|
+
// the canonical wrapper in kxco-post-quantum/src/ml-dsa.js.
|
|
84
|
+
try {
|
|
85
|
+
return ml_dsa65.verify(pk, msg, sig)
|
|
86
|
+
} catch {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Constant-time-ish hex comparison. Both inputs are hex strings.
|
|
93
|
+
* @param {string} a
|
|
94
|
+
* @param {string} b
|
|
95
|
+
* @returns {boolean}
|
|
96
|
+
*/
|
|
97
|
+
export function hexEquals(a, b) {
|
|
98
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false
|
|
99
|
+
if (a.length !== b.length) return false
|
|
100
|
+
let diff = 0
|
|
101
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
102
|
+
return diff === 0
|
|
103
|
+
}
|