kxco-pq-attest 2.0.6 → 2.0.7

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.0.7
4
+
5
+ An empty payload, as text or as bytes, now verifies. A plain object is signed as
6
+ the UTF-8 of its JSON text, typed arrays and DataViews as the bytes they cover,
7
+ and any other payload is refused with KxcoPqAttestError.
8
+
9
+ verify and verifyAsync refuse a field of another type than attest writes, and a
10
+ version 2 text field that is not one line of well-formed text, as a malformed
11
+ envelope, and return a result rather than throwing on JSON input. The anchored
12
+ modes read only the signed anchor of a version 2 envelope. requireBoth returns
13
+ `classical_invalid` for a classical block that is not text.
14
+
15
+ The README describes version 2 anchoring and object payloads.
16
+
3
17
  ## 2.0.6
4
18
 
5
19
  Documentation. No source change.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
11
11
  [![node](https://img.shields.io/node/v/kxco-pq-attest.svg)](https://nodejs.org)
12
12
 
13
- Signs arbitrary data (strings, Buffers, objects) with ML-DSA-65 (NIST FIPS 204) and produces a self-contained JSON envelope any counterparty can verify without trust delegation. Optionally anchors the envelope hash on Armature L1 via the KXCO relay, creating a permanent timestamped on-chain record.
13
+ Signs arbitrary data (strings, Buffers, objects) with ML-DSA-65 (NIST FIPS 204) and produces a self-contained JSON envelope any counterparty can verify without trust delegation. Optionally anchors the SHA-256 of the payload on Armature L1 via the KXCO relay, creating a permanent timestamped on-chain record.
14
14
 
15
15
  - **Verification needs nothing from us.** `verify(envelope, publicKey)` returns from the JSON alone: no endpoint, no account and no licence, now or in ten years.
16
16
  - **Every field is bound.** Payload, key id and issue time sit inside the signed bytes behind a version prefix, so no field can be reordered or replayed against a different timestamp.
@@ -77,7 +77,8 @@ const envelope = await attest(
77
77
  keypair,
78
78
  { anchor: true, purpose: 'invoice-sign', chain }
79
79
  )
80
- // envelope.chainAnchor: { txHash: '0x...', blockNumber: 1234567 }
80
+ // envelope.anchor: { txHash: '0x...', blockNumber: 1234567 }, inside the signature
81
+ // envelope.chainId: 1111111, when the relay confirmed the chain
81
82
  ```
82
83
 
83
84
  ## For institutions
@@ -108,14 +109,14 @@ Signs `payload` with ML-DSA-65 and returns an envelope. Returns a Promise.
108
109
 
109
110
  | Parameter | Type | Description |
110
111
  |-----------|------|-------------|
111
- | `payload` | `string \| Buffer \| Uint8Array` | Data to sign. Strings are UTF-8 encoded. |
112
+ | `payload` | `string \| Buffer \| Uint8Array \| object` | Data to sign. Strings are UTF-8 encoded. A plain object is signed as the UTF-8 of its JSON text. |
112
113
  | `keypair` | `{ secretKey, publicKey }` | ML-DSA-65 keypair from `kxco-post-quantum`. |
113
- | `options.anchor` | `boolean` | Default `false`. When `true`, anchors the envelope hash on-chain. Requires `chain`. |
114
+ | `options.anchor` | `boolean` | Default `false`. When `true`, anchors the SHA-256 of the payload on-chain. Requires `chain`. |
114
115
  | `options.purpose` | `string` | Optional label stored with the on-chain anchor (e.g. `'trade-confirm'`). |
115
116
  | `options.chain` | `object` | Armature L1 relay client. Required when `anchor: true`. Must implement `anchorAttestation({ payloadHash, purpose })`. |
116
117
  | `options.classical` | `{ alg, privateKey, publicKey }` | Optional Ed25519 or ECDSA-P256 co-signature over the same message the ML-DSA-65 signature covers. Generate the pair with `generateClassicalKeypair(alg)`, which uses WebCrypto and runs in Node, Workers and browsers. |
117
118
 
118
- When `anchor: true`, the envelope hash (SHA-256 of the signed JSON) is posted to the relay and the result is attached to the envelope as `chainAnchor`.
119
+ When `anchor: true`, the SHA-256 of the payload bytes is posted to the relay before signing, and the transaction hash and block number it returns are written into the envelope as `anchor`, inside the signed message. `chainId` is added, and signed too, when the relay confirmed the chain.
119
120
 
120
121
  ---
121
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxco-pq-attest",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "Post-quantum document signing any counterparty can verify offline, years later. ML-DSA-65 (NIST FIPS 204) over any payload in a self-contained JSON envelope, with optional anchoring on Armature L1 for a timestamp the chain itself has verified.",
5
5
  "keywords": [
6
6
  "post-quantum",
@@ -73,12 +73,15 @@
73
73
  "kxco-pq-network": "^1.0.0"
74
74
  },
75
75
  "scripts": {
76
- "test": "node --test --test-timeout=30000 test/attest.test.js test/envelope-v2.test.js",
76
+ "test": "node --test --test-timeout=30000 test/attest.test.js test/envelope-v2.test.js test/property.test.js",
77
77
  "evidence": "node scripts/build-evidence.mjs"
78
78
  },
79
79
  "funding": "https://kxco.ai",
80
80
  "publishConfig": {
81
81
  "provenance": true,
82
82
  "access": "public"
83
+ },
84
+ "devDependencies": {
85
+ "fast-check": "4.10.2"
83
86
  }
84
87
  }
package/src/attest.js CHANGED
@@ -46,6 +46,76 @@ function fromB64url(str) {
46
46
  return new Uint8Array(Buffer.from(str, 'base64url'))
47
47
  }
48
48
 
49
+ function isPlainObject(value) {
50
+ if (value === null || typeof value !== 'object') return false
51
+ const proto = Object.getPrototypeOf(value)
52
+ return proto === Object.prototype || proto === null
53
+ }
54
+
55
+ // The bytes a payload is signed as. Bytes as they are, a string as its UTF-8,
56
+ // and a plain object as the UTF-8 of its JSON text, which is how
57
+ // KxcoIdentity.attest in kxco-pq-sdk encodes one. Anything else has no single
58
+ // obvious byte form, so it is refused rather than guessed at.
59
+ function payloadBytesOf(payload) {
60
+ if (typeof payload === 'string') return enc.encode(payload)
61
+ if (payload instanceof Uint8Array || payload instanceof ArrayBuffer) return new Uint8Array(payload)
62
+ if (ArrayBuffer.isView(payload)) {
63
+ return new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength).slice()
64
+ }
65
+ if (isPlainObject(payload)) {
66
+ let json
67
+ try {
68
+ json = JSON.stringify(payload)
69
+ } catch (err) {
70
+ throw new KxcoPqAttestError(`payload object has no JSON text to sign: ${err.message}`)
71
+ }
72
+ if (typeof json !== 'string') throw new KxcoPqAttestError('payload object has no JSON text to sign')
73
+ return enc.encode(json)
74
+ }
75
+ throw new KxcoPqAttestError('payload must be a string, bytes (Uint8Array, Buffer or ArrayBuffer) or a plain object')
76
+ }
77
+
78
+ // ── field types ─────────────────────────────────────────────────────────────
79
+ //
80
+ // The signing messages are built from each field's text. A field of another
81
+ // type would be read as whatever its text happens to be: a one-element array
82
+ // as its element, an object as its toString. So every field that goes into a
83
+ // message must be the type attest() writes, and one that is not is refused as
84
+ // malformed. An optional field may be absent, and null reads as absent, which
85
+ // is how the messages have always treated it.
86
+
87
+ const isText = (v) => typeof v === 'string' && v !== ''
88
+ const isAbsent = (v) => v === undefined || v === null
89
+ const optionalText = (v) => isAbsent(v) || typeof v === 'string'
90
+ const optionalInteger = (v) => isAbsent(v) || Number.isInteger(v)
91
+
92
+ // The v2 message is also one field per line. A text field holding a line
93
+ // break would let the same signature cover the same bytes split into
94
+ // different fields, so none may hold one. The text has to be well-formed
95
+ // Unicode too, because an unpaired surrogate encodes to the same bytes as
96
+ // U+FFFD.
97
+ const isOneLine = (v) => typeof v === 'string' && !/[\r\n]/.test(v) && v.isWellFormed()
98
+ const optionalLine = (v) => isAbsent(v) || isOneLine(v)
99
+
100
+ function anchorWellTyped(anchor) {
101
+ if (isAbsent(anchor)) return true
102
+ return isPlainObject(anchor) && optionalLine(anchor.txHash) && optionalInteger(anchor.blockNumber)
103
+ }
104
+
105
+ // What the anchored modes may read. A v2 anchor sits inside the signed
106
+ // message, so they see only the signed fields: a chainAnchor attached after
107
+ // signing, or a chainId added inside the anchor, is never read and never
108
+ // returned. A v1 anchor was always attached after signing and is read as it
109
+ // is, which is the reason version 2 exists.
110
+ function signedAnchorView(envelope) {
111
+ if (envelope['kxco-attest'] !== V2) return envelope
112
+ const { anchor, chainId } = envelope
113
+ return {
114
+ ...(isAbsent(chainId) ? {} : { chainId }),
115
+ ...(isPlainObject(anchor) ? { anchor: { txHash: anchor.txHash, blockNumber: anchor.blockNumber } } : {}),
116
+ }
117
+ }
118
+
49
119
  // ── signing messages ────────────────────────────────────────────────────────
50
120
 
51
121
  // v1. Frozen. Any change here invalidates every envelope ever issued.
@@ -150,7 +220,7 @@ async function classicalVerify(alg, rawPublicKey, message, signature) {
150
220
  * With no options this is signature mode: no network, no licence, no chain,
151
221
  * and the envelope verifies offline forever.
152
222
  *
153
- * @param {string|Uint8Array|Buffer} payload
223
+ * @param {string|Uint8Array|Buffer|object} payload (a plain object is signed as its JSON text)
154
224
  * @param {{ publicKey: Uint8Array, secretKey: Uint8Array }} keypair
155
225
  * @param {object} [opts]
156
226
  * @param {boolean} [opts.anchor] — anchor the envelope on Armature L1. Needs `chain`.
@@ -179,8 +249,11 @@ export async function attest(payload, keypair, opts = {}) {
179
249
  if (version === V1 && (classical || verifyModeHint)) {
180
250
  throw new KxcoPqAttestError('classical co-signing and verifyModeHint need envelope version 2')
181
251
  }
252
+ if (!optionalLine(verifyModeHint)) {
253
+ throw new KxcoPqAttestError('verifyModeHint must be one line of well-formed text')
254
+ }
182
255
 
183
- const payloadBytes = typeof payload === 'string' ? enc.encode(payload) : new Uint8Array(payload)
256
+ const payloadBytes = payloadBytesOf(payload)
184
257
  const payloadB64 = b64url(payloadBytes)
185
258
  const kid = fingerprint(keypair.publicKey)
186
259
  const issuedAt = new Date().toISOString()
@@ -203,6 +276,13 @@ export async function attest(payload, keypair, opts = {}) {
203
276
  purpose: purpose ?? '',
204
277
  })
205
278
  chainAnchor = { txHash: result.txHash, blockNumber: result.blockNumber }
279
+ // Signed, so it has to be of the types verify() accepts, or the envelope
280
+ // would never verify.
281
+ if (!anchorWellTyped(chainAnchor)) {
282
+ throw new KxcoPqAttestError(
283
+ 'the chain client returned an anchor that is not { txHash: one line of text, blockNumber: integer }',
284
+ )
285
+ }
206
286
  // kxco-pq-chain 2.x reports this. An older client, or a bare object a
207
287
  // caller passed in, does not — and absence is not confirmation, so it is
208
288
  // read strictly rather than defaulted to true.
@@ -309,7 +389,7 @@ export function verify(envelope, publicKey, opts = {}) {
309
389
  if (mode === 'signature') return checked
310
390
 
311
391
  // anchored, decided from what the envelope carries.
312
- const anchor = readAnchor(envelope)
392
+ const anchor = readAnchor(signedAnchorView(envelope))
313
393
  if (!anchor) {
314
394
  return {
315
395
  valid: false,
@@ -361,7 +441,7 @@ export async function verifyAsync(envelope, publicKey, opts = {}) {
361
441
 
362
442
  const resolved = config ?? networkConfig({ verifyMode: mode ?? 'signature' })
363
443
  const applied = await applyVerifyMode({
364
- envelope,
444
+ envelope: signedAnchorView(envelope),
365
445
  signatureValid: true,
366
446
  kid: checked.signerKid,
367
447
  // An explicit mode argument beats the one baked into a shared config, so a
@@ -391,14 +471,20 @@ function checkSignature(envelope, publicKey) {
391
471
  }
392
472
 
393
473
  const version = envelope['kxco-attest']
394
- if (version === V1) return checkSignatureV1(envelope, publicKey)
395
- if (version === V2) return checkSignatureV2(envelope, publicKey)
396
- return { valid: false, error: 'unsupported version', reason: FAILURE.MALFORMED }
474
+ if (version !== V1 && version !== V2) {
475
+ return { valid: false, error: 'unsupported version', reason: FAILURE.MALFORMED }
476
+ }
477
+ // The anchored modes read the chain id an anchor names and write a wrong
478
+ // one into their answer. It has to be an integer to be read at all.
479
+ if (!optionalInteger(readAnchor(signedAnchorView(envelope))?.chainId)) {
480
+ return { valid: false, error: 'malformed envelope', reason: FAILURE.MALFORMED }
481
+ }
482
+ return version === V1 ? checkSignatureV1(envelope, publicKey) : checkSignatureV2(envelope, publicKey)
397
483
  }
398
484
 
399
485
  function checkSignatureV1(envelope, publicKey) {
400
486
  const { payload: payloadB64, kid, issuedAt, signature } = envelope
401
- if (!payloadB64 || !kid || !issuedAt || !signature) {
487
+ if (typeof payloadB64 !== 'string' || !isText(kid) || !isText(issuedAt) || !isText(signature)) {
402
488
  return { valid: false, error: 'malformed envelope', reason: FAILURE.MALFORMED }
403
489
  }
404
490
 
@@ -411,7 +497,8 @@ function checkSignatureV1(envelope, publicKey) {
411
497
 
412
498
  function checkSignatureV2(envelope, publicKey) {
413
499
  const { payload: payloadB64, alg, kid, sig, issuedAt, chainId, anchor, verifyModeHint } = envelope
414
- if (!payloadB64 || !kid || !sig || !issuedAt) {
500
+ if (![payloadB64, kid, issuedAt].every(isOneLine) || !isText(kid) || !isText(issuedAt) || !isText(sig) ||
501
+ !optionalText(alg) || !optionalInteger(chainId) || !anchorWellTyped(anchor) || !optionalLine(verifyModeHint)) {
415
502
  return { valid: false, error: 'malformed envelope', reason: FAILURE.MALFORMED }
416
503
  }
417
504
  // The algorithm is checked against what this package signs, not used to pick
@@ -458,6 +545,9 @@ async function checkClassical(envelope, pinnedPublicKey) {
458
545
  }
459
546
 
460
547
  const { alg, publicKey: envelopePublicKey, sig } = envelope.classical
548
+ if (typeof alg !== 'string' || typeof envelopePublicKey !== 'string' || typeof sig !== 'string') {
549
+ return { valid: false, error: 'malformed classical co-signature', reason: 'classical_invalid' }
550
+ }
461
551
  if (!CLASSICAL_ALGORITHMS.includes(alg)) {
462
552
  return { valid: false, error: `unsupported classical alg '${alg}'`, reason: 'classical_invalid' }
463
553
  }
package/src/index.d.ts CHANGED
@@ -158,10 +158,11 @@ export function verifyAsync(
158
158
  * Sign a payload into an attestation envelope.
159
159
  *
160
160
  * With no options this is signature mode: no network, no licence, no chain,
161
- * and the envelope verifies offline forever.
161
+ * and the envelope verifies offline forever. A plain object is signed as the
162
+ * UTF-8 of its JSON text.
162
163
  */
163
164
  export function attest(
164
- payload: string | Uint8Array | Buffer,
165
+ payload: string | Uint8Array | Buffer | Record<string, unknown>,
165
166
  keypair: Keypair,
166
167
  opts?: AttestOptions,
167
168
  ): Promise<AttestationEnvelope>