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.
@@ -0,0 +1,152 @@
1
+ // The three modes, applied to an envelope whose signature has already been
2
+ // checked.
3
+ //
4
+ // This module does not do cryptography. Each package owns its own envelope
5
+ // shape and its own signing message, and duplicating that here would mean two
6
+ // definitions of what a valid signature is. Instead the caller checks the
7
+ // maths and hands the outcome in, and this decides what the mode requires on
8
+ // top of it.
9
+ //
10
+ // The order matters: signature first, always. A revoked key that signed
11
+ // nothing should be reported as a bad signature, not as a revocation, because
12
+ // the two lead a reader to different conclusions about what happened.
13
+
14
+ import { CHAIN_ID } from './config.js'
15
+ import { FAILURE, KxcoPqNetworkError } from './errors.js'
16
+ import { KeyRegistry } from './registry.js'
17
+
18
+ /**
19
+ * Read the anchor out of an envelope, whichever of the two shapes it uses.
20
+ *
21
+ * kxco-pq-attest wrote `chainAnchor: { txHash, blockNumber }` before this
22
+ * package existed, and the current shape is `chainId` alongside
23
+ * `anchor: { txHash, blockNumber }`. Both are accepted so that envelopes
24
+ * already in customers' archives keep verifying.
25
+ *
26
+ * @param {object} envelope
27
+ * @returns {{ txHash: string, blockNumber?: number, chainId: number|undefined }|null}
28
+ */
29
+ export function readAnchor(envelope) {
30
+ if (!envelope || typeof envelope !== 'object') return null
31
+
32
+ const anchor = envelope.anchor ?? envelope.chainAnchor
33
+ if (!anchor || typeof anchor !== 'object') return null
34
+ if (typeof anchor.txHash !== 'string' || !/^0x[0-9a-fA-F]{64}$/.test(anchor.txHash)) return null
35
+
36
+ return {
37
+ txHash: anchor.txHash,
38
+ blockNumber: anchor.blockNumber,
39
+ // The envelope-level chainId is the current shape. An older envelope with
40
+ // only chainAnchor and no chainId is treated as unstated, not as wrong:
41
+ // it predates the field, and rejecting it would break archives.
42
+ chainId: envelope.chainId ?? anchor.chainId,
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Apply a verification mode.
48
+ *
49
+ * @param {object} opts
50
+ * @param {object} opts.envelope — the envelope being verified
51
+ * @param {boolean} opts.signatureValid — the caller's own signature check
52
+ * @param {string} opts.kid — the signing kid
53
+ * @param {object} opts.config — from networkConfig()
54
+ * @param {KeyRegistry} [opts.registry] — reuse one to share its cache
55
+ * @returns {Promise<{ valid: boolean, mode: string, reason?: string,
56
+ * detail?: string, anchor?: object, registry?: object }>}
57
+ */
58
+ export async function applyVerifyMode({ envelope, signatureValid, kid, config, registry }) {
59
+ const mode = config.verifyMode
60
+
61
+ if (!signatureValid) {
62
+ return { valid: false, mode, reason: FAILURE.SIGNATURE_INVALID }
63
+ }
64
+
65
+ if (mode === 'signature') {
66
+ return { valid: true, mode }
67
+ }
68
+
69
+ // ── anchored ──────────────────────────────────────────────────────────────
70
+
71
+ const anchor = readAnchor(envelope)
72
+ if (!anchor) {
73
+ return {
74
+ valid: false,
75
+ mode,
76
+ reason: FAILURE.NOT_ANCHORED,
77
+ detail:
78
+ 'this envelope carries no Armature L1 anchor. It was signed in signature mode; ' +
79
+ 're-issue it with anchor: true, or verify it in signature mode.',
80
+ }
81
+ }
82
+
83
+ if (anchor.chainId !== undefined && anchor.chainId !== CHAIN_ID) {
84
+ return {
85
+ valid: false,
86
+ mode,
87
+ reason: FAILURE.WRONG_CHAIN,
88
+ detail: `anchor names chain ${anchor.chainId}; this verifier accepts only ${CHAIN_ID} (Armature L1)`,
89
+ anchor,
90
+ }
91
+ }
92
+
93
+ if (mode === 'anchored') {
94
+ return { valid: true, mode, anchor }
95
+ }
96
+
97
+ // ── anchored+live ─────────────────────────────────────────────────────────
98
+
99
+ if (!config.licenceKey) {
100
+ return {
101
+ valid: false,
102
+ mode,
103
+ reason: FAILURE.LICENCE_REQUIRED,
104
+ detail:
105
+ 'anchored+live performs a live key-registry lookup and needs a licence key. ' +
106
+ 'Set KXCO_LICENCE_KEY, or use anchored, which needs none.',
107
+ anchor,
108
+ }
109
+ }
110
+
111
+ const client = registry ?? new KeyRegistry(config)
112
+
113
+ let record
114
+ try {
115
+ record = await client.lookup(kid)
116
+ } catch (err) {
117
+ if (!(err instanceof KxcoPqNetworkError)) throw err
118
+ // Fails closed. The point of this mode is to catch a key that is no longer
119
+ // good; degrading to "probably fine" when the registry is unreachable
120
+ // would remove exactly the protection the customer is paying for, at
121
+ // exactly the moment it is most likely to matter.
122
+ return {
123
+ valid: false,
124
+ mode,
125
+ reason: FAILURE.REGISTRY_UNREACHABLE,
126
+ detail: `${err.message}. anchored+live fails closed; use anchored for an offline answer.`,
127
+ anchor,
128
+ }
129
+ }
130
+
131
+ if (record.status === 'active') {
132
+ return { valid: true, mode, anchor, registry: record }
133
+ }
134
+
135
+ const reason = {
136
+ revoked: FAILURE.KID_REVOKED,
137
+ rotated: FAILURE.KID_ROTATED,
138
+ expired: FAILURE.KID_EXPIRED,
139
+ }[record.status] ?? FAILURE.KID_UNKNOWN
140
+
141
+ return {
142
+ valid: false,
143
+ mode,
144
+ reason,
145
+ detail:
146
+ record.status === 'rotated' && record.rotatedTo
147
+ ? `kid ${kid} was rotated to ${record.rotatedTo}`
148
+ : `registry reports kid ${kid} as ${record.status}`,
149
+ anchor,
150
+ registry: record,
151
+ }
152
+ }