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 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
+ [![npm](https://img.shields.io/npm/v/kxco-verify?label=npm&color=b0964f)](https://www.npmjs.com/package/kxco-verify)
4
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
5
+ [![verify.kxco.ai](https://img.shields.io/badge/verify.kxco.ai-live-22c55e)](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
+ }
@@ -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
+ }