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 +14 -0
- package/README.md +6 -5
- package/package.json +5 -2
- package/src/attest.js +99 -9
- package/src/index.d.ts +3 -2
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)
|
|
11
11
|
[](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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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 =
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
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 (
|
|
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 || !
|
|
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>
|