kxco-pq-network 1.0.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/src/config.js ADDED
@@ -0,0 +1,126 @@
1
+ // Network configuration, and the three verification modes.
2
+ //
3
+ // The modes are the product boundary, so they are worth stating plainly.
4
+ //
5
+ // signature the maths, and nothing else. Works offline, forever, with
6
+ // no KXCO server in the path and no licence. This is what
7
+ // kxco-post-quantum and kxco-verify do, and it stays free.
8
+ //
9
+ // anchored the maths, plus proof that the envelope was written to
10
+ // Armature L1: chain id 1111111 and a transaction hash inside
11
+ // the envelope. Still no HTTP at verify time — the anchor is
12
+ // carried by the envelope, so an air-gapped verifier can
13
+ // check it. What it cannot tell you is whether the key is
14
+ // still good today.
15
+ //
16
+ // anchored+live anchored, plus a live registry lookup that says whether the
17
+ // signing key is still active. This is the mode that answers
18
+ // "should I act on this now", and it is the one that needs a
19
+ // hosted registry.
20
+ //
21
+ // Only the maths is free. What we sell is the answer to whether a key is still
22
+ // allowed to sign, which is a fact about the present that no amount of
23
+ // offline cryptography can supply.
24
+
25
+ import { KxcoPqNetworkError } from './errors.js'
26
+
27
+ /** Armature L1. The only chain this package will accept an anchor from. */
28
+ export const CHAIN_ID = 1111111
29
+
30
+ export const DEFAULT_REGISTRY_URL = 'https://chain.kxco.ai'
31
+ export const DEFAULT_RELAY_URL = 'https://relay.kxco.ai'
32
+ export const DEFAULT_REGISTRY_TTL_MS = 60_000
33
+
34
+ export const VERIFY_MODES = ['signature', 'anchored', 'anchored+live']
35
+
36
+ /**
37
+ * Normalise and validate a network configuration.
38
+ *
39
+ * A missing licence key is not an error here. It becomes one at the point of
40
+ * use, where the message can say what the caller was actually trying to do:
41
+ * "signature" needs no licence at all, and refusing to construct a config
42
+ * without one would break the free path.
43
+ *
44
+ * @param {object} [config]
45
+ * @returns {Required<Pick<object,never>> & {
46
+ * chainId: number, registryUrl: string, relayUrl: string,
47
+ * verifyMode: string, licenceKey: string|null, registryTtlMs: number,
48
+ * fetchImpl: typeof fetch, timeoutMs: number,
49
+ * }}
50
+ */
51
+ export function networkConfig(config = {}) {
52
+ if (config === null || typeof config !== 'object') {
53
+ throw new KxcoPqNetworkError('network config must be an object', { code: 'BAD_CONFIG' })
54
+ }
55
+
56
+ const {
57
+ chainId = CHAIN_ID,
58
+ registryUrl = DEFAULT_REGISTRY_URL,
59
+ relayUrl = DEFAULT_RELAY_URL,
60
+ verifyMode = 'signature',
61
+ licenceKey = null,
62
+ registryTtlMs = DEFAULT_REGISTRY_TTL_MS,
63
+ fetchImpl,
64
+ timeoutMs = 10_000,
65
+ } = config
66
+
67
+ // A different chain id is not a configuration option. Accepting one would
68
+ // mean an anchor written to some other chain could satisfy a KXCO verify,
69
+ // which is the whole thing the anchor is supposed to prove.
70
+ if (chainId !== CHAIN_ID) {
71
+ throw new KxcoPqNetworkError(
72
+ `chainId must be ${CHAIN_ID} (Armature L1), got ${chainId}`,
73
+ { code: 'WRONG_CHAIN' },
74
+ )
75
+ }
76
+
77
+ if (!VERIFY_MODES.includes(verifyMode)) {
78
+ throw new KxcoPqNetworkError(
79
+ `unknown verifyMode '${verifyMode}' — expected one of ${VERIFY_MODES.join(', ')}`,
80
+ { code: 'BAD_CONFIG' },
81
+ )
82
+ }
83
+
84
+ for (const [name, value] of [['registryUrl', registryUrl], ['relayUrl', relayUrl]]) {
85
+ if (typeof value !== 'string' || !/^https?:\/\//.test(value)) {
86
+ throw new KxcoPqNetworkError(`${name} must be an http(s) URL, got ${value}`, { code: 'BAD_CONFIG' })
87
+ }
88
+ }
89
+
90
+ if (!Number.isFinite(registryTtlMs) || registryTtlMs < 0) {
91
+ throw new KxcoPqNetworkError('registryTtlMs must be a non-negative number', { code: 'BAD_CONFIG' })
92
+ }
93
+
94
+ return {
95
+ chainId,
96
+ registryUrl: registryUrl.replace(/\/$/, ''),
97
+ relayUrl: relayUrl.replace(/\/$/, ''),
98
+ verifyMode,
99
+ licenceKey: licenceKey || null,
100
+ registryTtlMs,
101
+ // Captured so a test can inject a mock without touching globals, and so a
102
+ // caller in a runtime with a scoped fetch can pass their own.
103
+ fetchImpl: fetchImpl ?? globalThis.fetch,
104
+ timeoutMs,
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Read a configuration out of the environment.
110
+ *
111
+ * This is the whole of what a customer has to set to go live, which is why it
112
+ * is one function and not a framework.
113
+ *
114
+ * @param {Record<string,string|undefined>} [env]
115
+ * @param {object} [overrides]
116
+ */
117
+ export function networkConfigFromEnv(env = process.env, overrides = {}) {
118
+ return networkConfig({
119
+ registryUrl: env.KXCO_REGISTRY_URL || undefined,
120
+ relayUrl: env.KXCO_RELAY_URL || undefined,
121
+ verifyMode: env.KXCO_VERIFY_MODE || undefined,
122
+ licenceKey: env.KXCO_LICENCE_KEY || env.KXCO_LICENSE_KEY || null,
123
+ registryTtlMs: env.KXCO_REGISTRY_TTL_MS ? Number(env.KXCO_REGISTRY_TTL_MS) : undefined,
124
+ ...overrides,
125
+ })
126
+ }
package/src/errors.js ADDED
@@ -0,0 +1,45 @@
1
+ // One error type, with a code a caller can branch on.
2
+ //
3
+ // The codes matter more than the messages here, because the two failures a
4
+ // customer must be able to tell apart — "this signature is forged" and "we
5
+ // could not reach the registry" — look identical in a boolean.
6
+
7
+ export class KxcoPqNetworkError extends Error {
8
+ /**
9
+ * @param {string} message
10
+ * @param {{ code?: string, status?: number|null, cause?: unknown }} [opts]
11
+ */
12
+ constructor(message, { code = 'NETWORK_ERROR', status = null, cause } = {}) {
13
+ super(message, cause === undefined ? undefined : { cause })
14
+ this.name = 'KxcoPqNetworkError'
15
+ this.code = code
16
+ this.status = status
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Reasons a verification failed. A caller that treats every one of these the
22
+ * same way is running a weaker check than it thinks it is.
23
+ */
24
+ export const FAILURE = {
25
+ /** The signature does not check out. The document is not what it claims. */
26
+ SIGNATURE_INVALID: 'signature_invalid',
27
+ /** Envelope structure is wrong or a required field is missing. */
28
+ MALFORMED: 'malformed',
29
+ /** The mode requires an anchor and the envelope carries none. */
30
+ NOT_ANCHORED: 'not_anchored',
31
+ /** The anchor names a chain that is not Armature L1. */
32
+ WRONG_CHAIN: 'wrong_chain',
33
+ /** The registry says this key was revoked. */
34
+ KID_REVOKED: 'kid_revoked',
35
+ /** The registry says this key was rotated out. */
36
+ KID_ROTATED: 'kid_rotated',
37
+ /** The credential is past its on-chain expiry. Not revoked — it ran out. */
38
+ KID_EXPIRED: 'kid_expired',
39
+ /** The registry has never heard of this key. */
40
+ KID_UNKNOWN: 'kid_unknown',
41
+ /** The registry could not be reached. Fails closed — see verifyEnvelope. */
42
+ REGISTRY_UNREACHABLE: 'registry_unreachable',
43
+ /** The mode needs a licence key and none was configured. */
44
+ LICENCE_REQUIRED: 'licence_required',
45
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,169 @@
1
+ /// <reference types="node" />
2
+
3
+ /** Armature L1. The only chain an anchor may name. */
4
+ export const CHAIN_ID: 1111111
5
+
6
+ export const DEFAULT_REGISTRY_URL: 'https://chain.kxco.ai'
7
+ export const DEFAULT_RELAY_URL: 'https://relay.kxco.ai'
8
+ export const DEFAULT_REGISTRY_TTL_MS: 60000
9
+
10
+ /**
11
+ * - `signature` — crypto only. Offline, free, no KXCO server in the path.
12
+ * - `anchored` — crypto, plus an Armature L1 anchor carried by the
13
+ * envelope. Still no HTTP at verify time.
14
+ * - `anchored+live` — anchored, plus a live registry lookup. Fails closed if
15
+ * the registry is unreachable. Requires a licence key.
16
+ */
17
+ export type VerifyMode = 'signature' | 'anchored' | 'anchored+live'
18
+
19
+ export const VERIFY_MODES: VerifyMode[]
20
+
21
+ export interface NetworkConfigInput {
22
+ /** Must be 1111111 if given. Any other value throws. */
23
+ chainId?: 1111111
24
+ /** Defaults to https://chain.kxco.ai */
25
+ registryUrl?: string
26
+ /** Defaults to https://relay.kxco.ai */
27
+ relayUrl?: string
28
+ /** Defaults to 'signature'. */
29
+ verifyMode?: VerifyMode
30
+ licenceKey?: string | null
31
+ /** Defaults to 60000. Zero disables the cache. */
32
+ registryTtlMs?: number
33
+ /** Defaults to globalThis.fetch. Inject to test, or to use a scoped fetch. */
34
+ fetchImpl?: typeof fetch
35
+ /** Defaults to 10000. */
36
+ timeoutMs?: number
37
+ }
38
+
39
+ export interface NetworkConfig {
40
+ chainId: 1111111
41
+ registryUrl: string
42
+ relayUrl: string
43
+ verifyMode: VerifyMode
44
+ licenceKey: string | null
45
+ registryTtlMs: number
46
+ fetchImpl: typeof fetch
47
+ timeoutMs: number
48
+ }
49
+
50
+ /** Normalise and validate a configuration. Throws on a wrong chain id or mode. */
51
+ export function networkConfig(config?: NetworkConfigInput): NetworkConfig
52
+
53
+ /**
54
+ * Read a configuration from the environment:
55
+ * `KXCO_REGISTRY_URL`, `KXCO_RELAY_URL`, `KXCO_VERIFY_MODE`,
56
+ * `KXCO_LICENCE_KEY` (or `KXCO_LICENSE_KEY`), `KXCO_REGISTRY_TTL_MS`.
57
+ */
58
+ export function networkConfigFromEnv(
59
+ env?: Record<string, string | undefined>,
60
+ overrides?: NetworkConfigInput,
61
+ ): NetworkConfig
62
+
63
+ // ── registry ────────────────────────────────────────────────────────────────
64
+
65
+ export type KidStatus = 'active' | 'revoked' | 'rotated' | 'expired' | 'unknown'
66
+
67
+ export const KID_STATUS: ['active', 'revoked', 'rotated', 'expired']
68
+
69
+ export interface KidRecord {
70
+ kid: string
71
+ status: KidStatus
72
+ publicKey?: string
73
+ rotatedTo?: string | null
74
+ institutionId?: string
75
+ chainId: 1111111
76
+ asOfBlock?: number
77
+ /** Whether this answer came from the TTL cache. */
78
+ cached: boolean
79
+ }
80
+
81
+ export class KeyRegistry {
82
+ constructor(config: NetworkConfig)
83
+ /**
84
+ * `GET /kids/:kid`. Cached for `registryTtlMs`; concurrent lookups of the
85
+ * same kid share one request.
86
+ *
87
+ * A 404 resolves to `status: 'unknown'` — the registry was reached and does
88
+ * not know the key. Being unable to reach it throws instead, so a caller can
89
+ * tell "definitely not trusted" from "could not ask".
90
+ */
91
+ lookup(kid: string): Promise<KidRecord>
92
+ clearCache(): void
93
+ }
94
+
95
+ // ── modes ───────────────────────────────────────────────────────────────────
96
+
97
+ export const FAILURE: {
98
+ SIGNATURE_INVALID: 'signature_invalid'
99
+ MALFORMED: 'malformed'
100
+ NOT_ANCHORED: 'not_anchored'
101
+ WRONG_CHAIN: 'wrong_chain'
102
+ KID_REVOKED: 'kid_revoked'
103
+ KID_ROTATED: 'kid_rotated'
104
+ KID_EXPIRED: 'kid_expired'
105
+ KID_UNKNOWN: 'kid_unknown'
106
+ REGISTRY_UNREACHABLE: 'registry_unreachable'
107
+ LICENCE_REQUIRED: 'licence_required'
108
+ }
109
+
110
+ export interface EnvelopeAnchor {
111
+ txHash: string
112
+ blockNumber?: number
113
+ chainId?: number
114
+ }
115
+
116
+ /** Read the anchor from either the current `anchor` shape or the legacy `chainAnchor`. */
117
+ export function readAnchor(envelope: unknown): EnvelopeAnchor | null
118
+
119
+ export interface VerifyModeResult {
120
+ valid: boolean
121
+ mode: VerifyMode
122
+ /** One of FAILURE's values, when invalid. */
123
+ reason?: string
124
+ /** Human-readable detail, when invalid. */
125
+ detail?: string
126
+ anchor?: EnvelopeAnchor
127
+ registry?: KidRecord
128
+ }
129
+
130
+ /**
131
+ * Apply a verification mode on top of a signature check the caller performed.
132
+ *
133
+ * This does no cryptography: each package owns its own envelope shape and
134
+ * signing message, and a second definition of "valid signature" here would be
135
+ * one definition too many.
136
+ */
137
+ export function applyVerifyMode(opts: {
138
+ envelope: unknown
139
+ signatureValid: boolean
140
+ kid: string
141
+ config: NetworkConfig
142
+ /** Pass one to share its cache across verifications. */
143
+ registry?: KeyRegistry
144
+ }): Promise<VerifyModeResult>
145
+
146
+ // ── errors and metering ─────────────────────────────────────────────────────
147
+
148
+ export class KxcoPqNetworkError extends Error {
149
+ name: 'KxcoPqNetworkError'
150
+ code: string
151
+ status: number | null
152
+ }
153
+
154
+ export const EVENTS: string[]
155
+
156
+ /** First 8 characters of a licence key. Never the whole key. */
157
+ export function licencePrefix(licenceKey: string | null | undefined): string | null
158
+
159
+ export function usageEvent(
160
+ event: string,
161
+ fields?: { institutionId?: string; kid?: string; licenceKey?: string; [k: string]: unknown },
162
+ ): Record<string, unknown>
163
+
164
+ /** Build and emit a usage event. Default sink is one JSON line on stdout. */
165
+ export function meter(
166
+ event: string,
167
+ fields?: Record<string, unknown>,
168
+ sink?: (record: Record<string, unknown>) => void,
169
+ ): Record<string, unknown>
package/src/index.js ADDED
@@ -0,0 +1,27 @@
1
+ // kxco-pq-network — the verification modes, and the registry behind them.
2
+ //
3
+ // This is the boundary between what is free and what is sold. The maths lives
4
+ // upstream in kxco-post-quantum and kxco-verify: Apache-2.0, chain-agnostic,
5
+ // works offline, no server. What lives here is the answer to whether a key is
6
+ // still allowed to sign, which is a fact about the present that no offline
7
+ // check can supply.
8
+ //
9
+ // Depended on by kxco-pq-sdk, kxco-pq-attest, kxco-post-quantum-webhook and
10
+ // kxco-pq-agent. It is its own package rather than a module inside the SDK
11
+ // because the SDK already depends on kxco-pq-attest, and putting it there
12
+ // would make that cycle.
13
+
14
+ export {
15
+ networkConfig,
16
+ networkConfigFromEnv,
17
+ CHAIN_ID,
18
+ VERIFY_MODES,
19
+ DEFAULT_REGISTRY_URL,
20
+ DEFAULT_RELAY_URL,
21
+ DEFAULT_REGISTRY_TTL_MS,
22
+ } from './config.js'
23
+
24
+ export { KeyRegistry, KID_STATUS } from './registry.js'
25
+ export { applyVerifyMode, readAnchor } from './verify-mode.js'
26
+ export { KxcoPqNetworkError, FAILURE } from './errors.js'
27
+ export { meter, usageEvent, licencePrefix, EVENTS } from './meter.js'
package/src/meter.js ADDED
@@ -0,0 +1,65 @@
1
+ // Structured usage events.
2
+ //
3
+ // Seats are priced per institution, and anchors and registry reads are
4
+ // metered, so the code that performs them has to say so in a shape a billing
5
+ // pipeline can read. This is the metre hook and nothing more: it emits, it
6
+ // does not aggregate, invoice, or phone home.
7
+ //
8
+ // The licence key is never emitted in full. A prefix is enough to attribute an
9
+ // event to a customer and useless to anyone who intercepts a log line, and
10
+ // logs get shipped to places the key was never meant to reach.
11
+
12
+ /** Events this package and its callers emit. */
13
+ export const EVENTS = [
14
+ 'anchor_written',
15
+ 'kid_registered',
16
+ 'kid_revoked',
17
+ 'kid_rotated',
18
+ 'relay_post',
19
+ 'registry_read',
20
+ 'verify',
21
+ ]
22
+
23
+ const LICENCE_PREFIX_LENGTH = 8
24
+
25
+ /** First 8 characters of a licence key, for attribution. Never the whole key. */
26
+ export function licencePrefix(licenceKey) {
27
+ if (typeof licenceKey !== 'string' || licenceKey.length === 0) return null
28
+ return licenceKey.slice(0, LICENCE_PREFIX_LENGTH)
29
+ }
30
+
31
+ /**
32
+ * Build a usage event. Returns the record rather than writing it, so the
33
+ * caller's own logger decides where it goes.
34
+ *
35
+ * @param {string} event — one of EVENTS
36
+ * @param {{ institutionId?: string, kid?: string, licenceKey?: string,
37
+ * [k: string]: unknown }} fields
38
+ */
39
+ export function usageEvent(event, fields = {}) {
40
+ const { licenceKey, ...rest } = fields
41
+ return {
42
+ event,
43
+ at: new Date().toISOString(),
44
+ chainId: 1111111,
45
+ ...rest,
46
+ licencePrefix: licencePrefix(licenceKey),
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Emit a usage event through a sink.
52
+ *
53
+ * The default sink is a single JSON line on stdout, because that is what every
54
+ * log shipper already parses. Pass your own to send it somewhere else.
55
+ *
56
+ * @param {string} event
57
+ * @param {object} fields
58
+ * @param {(record: object) => void} [sink]
59
+ */
60
+ export function meter(event, fields = {}, sink) {
61
+ const record = usageEvent(event, fields)
62
+ if (sink) sink(record)
63
+ else console.log(JSON.stringify(record))
64
+ return record
65
+ }
@@ -0,0 +1,232 @@
1
+ // Key registry client: GET /kids/:kid against the Armature L1 explorer.
2
+ //
3
+ // This answers the one question offline cryptography cannot: is this key still
4
+ // allowed to sign, today. A signature made by a key that was revoked an hour
5
+ // ago is still a perfectly valid signature, and a verifier that only checks
6
+ // the maths will accept it forever.
7
+ //
8
+ // Two design points are load-bearing.
9
+ //
10
+ // It fails CLOSED. If the registry cannot be reached, "anchored+live" returns
11
+ // invalid, not valid-with-a-warning. A mode whose whole purpose is to catch a
12
+ // revoked key must not degrade into the mode that cannot, precisely when the
13
+ // network is behaving oddly. Callers who would rather have the weaker answer
14
+ // than no answer can ask for "anchored" and get it deterministically.
15
+ //
16
+ // It caches by kid for registryTtlMs. Revocation is therefore visible within
17
+ // the TTL and not instantly, which is a real limit and is stated as one: at
18
+ // the 60 second default a revoked key stays accepted for up to a minute.
19
+ // Turning the cache off (registryTtlMs: 0) trades that for a lookup per
20
+ // verification.
21
+
22
+ import { kidEquals } from 'kxco-post-quantum'
23
+ import { KxcoPqNetworkError } from './errors.js'
24
+ import { CHAIN_ID } from './config.js'
25
+
26
+ // Whether a response claims to be JSON. Used to tell a registry answering from
27
+ // a web server answering, which matters most on a 404: one is a fact about a
28
+ // key, the other is a fact about the URL.
29
+ function isJson(response) {
30
+ // A content-type is a media type plus parameters. Splitting it is exact,
31
+ // and it avoids a regex over a header a remote server chose.
32
+ const type = response.headers?.get?.('content-type') ?? ''
33
+ const mediaType = type.split(';')[0].trim().toLowerCase()
34
+ return mediaType === 'application/json' || mediaType.endsWith('+json')
35
+ }
36
+
37
+ /**
38
+ * Statuses the registry may report. Anything else is treated as unknown.
39
+ *
40
+ * `expired` is here because the on-chain credential carries an `expiresAt` and
41
+ * the registry reports it. Folding it into `unknown` would still fail closed,
42
+ * but it would tell an operator "we have never heard of this key" about a key
43
+ * that is registered, was never revoked, and simply ran out — which sends them
44
+ * looking for the wrong problem.
45
+ */
46
+ export const KID_STATUS = ['active', 'revoked', 'rotated', 'expired']
47
+
48
+ export class KeyRegistry {
49
+ #url
50
+ #ttlMs
51
+ #fetch
52
+ #timeoutMs
53
+ #licenceKey
54
+ #cache = new Map() // kid -> { record, expiresAt }
55
+ #inflight = new Map() // kid -> Promise, so a burst is one request
56
+
57
+ /**
58
+ * @param {object} config — from networkConfig()
59
+ */
60
+ constructor(config) {
61
+ this.#url = config.registryUrl
62
+ this.#ttlMs = config.registryTtlMs
63
+ this.#fetch = config.fetchImpl
64
+ this.#timeoutMs = config.timeoutMs
65
+ this.#licenceKey = config.licenceKey
66
+
67
+ if (typeof this.#fetch !== 'function') {
68
+ throw new KxcoPqNetworkError(
69
+ 'no fetch implementation available — pass fetchImpl in the network config',
70
+ { code: 'BAD_CONFIG' },
71
+ )
72
+ }
73
+ }
74
+
75
+ /** Drop everything cached. For tests, and for a caller reacting to a rotation webhook. */
76
+ clearCache() {
77
+ this.#cache.clear()
78
+ }
79
+
80
+ /**
81
+ * Look a kid up, through the cache.
82
+ *
83
+ * @param {string} kid
84
+ * @returns {Promise<{ kid: string, status: string, publicKey?: string,
85
+ * rotatedTo?: string|null, institutionId?: string,
86
+ * chainId?: number, asOfBlock?: number, cached: boolean }>}
87
+ * @throws {KxcoPqNetworkError} if the registry cannot be reached or answers badly
88
+ */
89
+ async lookup(kid) {
90
+ if (typeof kid !== 'string' || !/^[0-9a-f]{16}$/.test(kid)) {
91
+ throw new KxcoPqNetworkError(`kid must be 16 lowercase hex characters, got '${kid}'`, {
92
+ code: 'BAD_KID',
93
+ })
94
+ }
95
+
96
+ const now = Date.now()
97
+ const hit = this.#cache.get(kid)
98
+ if (hit && hit.expiresAt > now) return { ...hit.record, cached: true }
99
+
100
+ // Collapse a burst for the same kid into one request. Without this, a
101
+ // batch of envelopes from one signer opens one connection per envelope at
102
+ // exactly the moment the cache is cold.
103
+ const existing = this.#inflight.get(kid)
104
+ if (existing) return { ...(await existing), cached: false }
105
+
106
+ const request = this.#fetchKid(kid)
107
+ .then((record) => {
108
+ if (this.#ttlMs > 0) {
109
+ this.#cache.set(kid, { record, expiresAt: Date.now() + this.#ttlMs })
110
+ }
111
+ return record
112
+ })
113
+ .finally(() => this.#inflight.delete(kid))
114
+
115
+ this.#inflight.set(kid, request)
116
+ return { ...(await request), cached: false }
117
+ }
118
+
119
+ async #fetchKid(kid) {
120
+ const url = `${this.#url}/kids/${kid}`
121
+ const ac = new AbortController()
122
+ const tid = setTimeout(() => ac.abort(), this.#timeoutMs)
123
+
124
+ let response
125
+ try {
126
+ response = await this.#fetch(url, {
127
+ method: 'GET',
128
+ headers: {
129
+ accept: 'application/json',
130
+ // The registry read is metered per licence where one is configured.
131
+ // Reads without a licence are allowed, rate-limited, and are how the
132
+ // free path stays free.
133
+ ...(this.#licenceKey ? { authorization: `Bearer ${this.#licenceKey}` } : {}),
134
+ },
135
+ signal: ac.signal,
136
+ })
137
+ } catch (err) {
138
+ throw new KxcoPqNetworkError(
139
+ err.name === 'AbortError'
140
+ ? `registry lookup timed out after ${this.#timeoutMs}ms`
141
+ : `registry unreachable: ${err.message}`,
142
+ { code: 'REGISTRY_UNREACHABLE', cause: err },
143
+ )
144
+ } finally {
145
+ clearTimeout(tid)
146
+ }
147
+
148
+ // A 404 from the REGISTRY is an answer, not a failure: it was reached and
149
+ // it has never heard of this kid. That is a definite "do not trust", and
150
+ // it must not be confused with "we could not ask".
151
+ //
152
+ // A 404 from something that is not the registry is the opposite. Pointing
153
+ // registryUrl at a web app gets back that app's 404 page, and reading a
154
+ // page of HTML as "this key is unknown" would state a fact about a key on
155
+ // the strength of a misconfigured URL. Measured, not hypothetical:
156
+ // https://chain.kxco.ai/kids/<kid> currently serves 26KB of the marketing
157
+ // site's 404 page.
158
+ //
159
+ // So the content type decides which of the two this is. JSON is the
160
+ // registry answering; anything else is a wrong address, and a wrong
161
+ // address is a reason we could not ask.
162
+ if (response.status === 404) {
163
+ if (!isJson(response)) {
164
+ throw new KxcoPqNetworkError(
165
+ `registry returned a non-JSON 404 from ${url} — this is a web page, not a registry ` +
166
+ 'answer. Check registryUrl: reading it as "kid unknown" would state a fact about a ' +
167
+ 'key on the strength of a misconfigured URL.',
168
+ { code: 'REGISTRY_UNREACHABLE', status: 404 },
169
+ )
170
+ }
171
+ return { kid, status: 'unknown', chainId: CHAIN_ID }
172
+ }
173
+
174
+ if (!response.ok) {
175
+ throw new KxcoPqNetworkError(`registry returned ${response.status}`, {
176
+ code: 'REGISTRY_UNREACHABLE',
177
+ status: response.status,
178
+ })
179
+ }
180
+
181
+ let body
182
+ try {
183
+ body = await response.json()
184
+ } catch (err) {
185
+ throw new KxcoPqNetworkError(
186
+ `registry returned a non-JSON body from ${url}` +
187
+ (isJson(response) ? '' : ' (content-type is not JSON — check registryUrl)'),
188
+ { code: 'REGISTRY_UNREACHABLE', status: response.status, cause: err },
189
+ )
190
+ }
191
+
192
+ return this.#validate(kid, body)
193
+ }
194
+
195
+ #validate(kid, body) {
196
+ if (!body || typeof body !== 'object') {
197
+ throw new KxcoPqNetworkError('registry record is not an object', { code: 'REGISTRY_BAD_RECORD' })
198
+ }
199
+
200
+ // The kid is compared in constant time, and it is compared at all because
201
+ // a registry that answers about a different key than the one asked for is
202
+ // either broken or being interposed. Either way the answer is unusable.
203
+ if (typeof body.kid !== 'string' || !kidEquals(body.kid, kid)) {
204
+ throw new KxcoPqNetworkError(
205
+ `registry answered for kid '${body.kid}' but was asked about '${kid}'`,
206
+ { code: 'REGISTRY_BAD_RECORD' },
207
+ )
208
+ }
209
+
210
+ if (body.chainId !== undefined && body.chainId !== CHAIN_ID) {
211
+ throw new KxcoPqNetworkError(
212
+ `registry record names chain ${body.chainId}, expected ${CHAIN_ID}`,
213
+ { code: 'WRONG_CHAIN' },
214
+ )
215
+ }
216
+
217
+ // An unrecognised status becomes 'unknown' rather than being passed
218
+ // through. A future status this build does not understand must not fall
219
+ // out of the "is it active" check as though it were active.
220
+ const status = KID_STATUS.includes(body.status) ? body.status : 'unknown'
221
+
222
+ return {
223
+ kid,
224
+ status,
225
+ publicKey: body.publicKey,
226
+ rotatedTo: body.rotatedTo ?? null,
227
+ institutionId: body.institutionId,
228
+ chainId: CHAIN_ID,
229
+ asOfBlock: body.asOfBlock,
230
+ }
231
+ }
232
+ }