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/CHANGELOG.md +84 -0
- package/LICENSE +202 -0
- package/README.md +184 -0
- package/SALES-SKU.md +134 -0
- package/package.json +67 -0
- package/src/config.js +126 -0
- package/src/errors.js +45 -0
- package/src/index.d.ts +169 -0
- package/src/index.js +27 -0
- package/src/meter.js +65 -0
- package/src/registry.js +232 -0
- package/src/verify-mode.js +152 -0
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
|
+
}
|
package/src/registry.js
ADDED
|
@@ -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
|
+
}
|