kxco-pq-agent 1.0.9 → 1.1.1
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/ASSESSMENT.md +95 -85
- package/CHANGELOG.md +70 -0
- package/LICENSE +1 -1
- package/NOTICE +6 -0
- package/README.md +141 -48
- package/package.json +19 -4
- package/src/agent-client.js +25 -6
- package/src/agent-identity.js +49 -2
- package/src/index.d.ts +28 -1
- package/src/index.js +1 -1
- package/src/jcs.js +14 -4
- package/src/scope.js +186 -4
package/ASSESSMENT.md
CHANGED
|
@@ -1,107 +1,117 @@
|
|
|
1
1
|
# Assessment notes
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
The answers a buyer's readiness assessment asks for: what this package does,
|
|
4
|
+
how it moves when algorithms move, and what it takes to run it.
|
|
5
5
|
|
|
6
6
|
Algorithm conformance belongs to
|
|
7
|
-
[`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
##
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
```
|
|
45
|
-
echo | openssl s_client -connect relay.kxco.ai:443 -servername relay.kxco.ai \
|
|
46
|
-
-groups X25519MLKEM768 -tls1_3 2>&1 | grep "Negotiated TLS1.3 group"
|
|
7
|
+
[`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), which
|
|
8
|
+
runs 2,103 NIST ACVP vectors and a cross-implementation interoperability matrix
|
|
9
|
+
and publishes the lot. Cited here, proven there.
|
|
10
|
+
|
|
11
|
+
## What this package is
|
|
12
|
+
|
|
13
|
+
Identity for a non-human actor. A KYC-verified institution sponsors an ML-DSA-65
|
|
14
|
+
keypair for an agent and binds a capability scope to it at issuance.
|
|
15
|
+
|
|
16
|
+
The question a supervisor asks about an autonomous system is not "was it
|
|
17
|
+
encrypted" but "who authorised this, and what were they allowed to do". This
|
|
18
|
+
package is built to answer exactly that, and three properties hold:
|
|
19
|
+
|
|
20
|
+
**An agent cannot widen its own authority.** The scope is signed by the sponsor
|
|
21
|
+
at issuance and hashed on chain, so it cannot be edited afterwards. Widening
|
|
22
|
+
requires revoke and re-issue by the sponsor. Compromising the agent does not
|
|
23
|
+
enlarge what the agent may do, which is the property that makes an autonomous
|
|
24
|
+
key safe to deploy at all.
|
|
25
|
+
|
|
26
|
+
**An agent cannot mint another agent.** Only a KYC-verified sponsor issues an
|
|
27
|
+
identity. There is no path from one compromised agent to a population of them.
|
|
28
|
+
|
|
29
|
+
**Every agent traces to a named legal entity.** No anonymous mode, no
|
|
30
|
+
self-signed mode. When a regulator asks who authorised an action, the answer is
|
|
31
|
+
an institution that completed KYC, not a key of unknown provenance.
|
|
32
|
+
|
|
33
|
+
**Enforcement runs at both ends.** The relay checks scope behind the agent,
|
|
34
|
+
where a compromised agent cannot reach it, and `checkScope()` checks the same
|
|
35
|
+
signed scope in front:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
const decision = checkScope(agent.scope, {
|
|
39
|
+
type: 'payment', amount: 4500, spentToday: 12000, recipient: '0xAbC…',
|
|
40
|
+
})
|
|
41
|
+
// { allowed: false, reason: 'amount 4500 would take today's total to 16500,
|
|
42
|
+
// past maxPerDay 15000', checked: ['payments.enabled', …] }
|
|
47
43
|
```
|
|
48
44
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
45
|
+
It refuses offline, so an agent that cannot reach the relay still knows what it
|
|
46
|
+
may not do. It refuses without spending a round trip. And it names the limit that
|
|
47
|
+
stopped it, which a remote refusal cannot do as precisely. It fails closed
|
|
48
|
+
throughout: a capability the scope does not grant is denied, an action type it
|
|
49
|
+
does not recognise is denied, and a configured limit that cannot be judged from
|
|
50
|
+
the inputs given is denied rather than skipped. `checked` reports every limit
|
|
51
|
+
the decision actually evaluated, so a caller can see the control was applied
|
|
52
|
+
rather than assume it.
|
|
53
|
+
|
|
54
|
+
**Expiry is mandatory.** `expiresIn` is required at creation. An agent identity
|
|
55
|
+
cannot be issued without an end date, which is the default that stops a
|
|
56
|
+
short-lived task leaving a long-lived key behind.
|
|
57
|
+
|
|
58
|
+
## Scope
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
60
|
+
The relay's enforcement is the one that binds, because it sits where the agent
|
|
61
|
+
cannot influence it, and `checkScope()` is defence in depth in front of it.
|
|
62
|
+
Assess both: the local check for fast, offline, precise refusal, and the relay
|
|
63
|
+
for the guarantee.
|
|
57
64
|
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
The sponsor's KYC is an operational control at KXCO rather than a protocol one,
|
|
66
|
+
and it is the thing that makes attribution to a legal entity meaningful.
|
|
67
|
+
|
|
68
|
+
Actions land where they land: on Armature L1 through
|
|
69
|
+
[`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain), or in whatever
|
|
70
|
+
record the sponsor keeps, for which
|
|
71
|
+
[`kxco-pq-audit`](https://www.npmjs.com/package/kxco-pq-audit) is the
|
|
72
|
+
append-only option.
|
|
60
73
|
|
|
61
74
|
## Agility
|
|
62
75
|
|
|
63
|
-
**Inherited
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
**Inherited.** Signing primitives belong to `kxco-post-quantum`.
|
|
77
|
+
|
|
78
|
+
**Coordinated by design.** An agent identity is registered on chain and its
|
|
79
|
+
scope is checked by the relay, so a parameter-set change moves through the chain
|
|
80
|
+
and the relay before it reaches the client. That ordering is correct: an
|
|
81
|
+
identity nobody can verify is worse than one that waits, and it is why the
|
|
82
|
+
migration story for this package is the chain's rather than its own.
|
|
68
83
|
|
|
69
|
-
**The scope manifest is a data
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
marker inside it to distinguish a future shape from the current one. Adding a
|
|
73
|
-
capability class later is therefore a change that both signer and enforcer must
|
|
74
|
-
agree on out of band. Worth noting beside the version prefixes that
|
|
75
|
-
`kxco-pq-attest` and `kxco-pq-tls` do carry.
|
|
84
|
+
**The scope manifest is a signed data structure** with named capability
|
|
85
|
+
sections, so adding a capability class is a change the sponsor signs and the
|
|
86
|
+
enforcer recognises, agreed between the two rather than assumed by either.
|
|
76
87
|
|
|
77
|
-
##
|
|
88
|
+
## Running it
|
|
78
89
|
|
|
79
|
-
**
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
90
|
+
**Release integrity.** Every release carries a SLSA provenance attestation and
|
|
91
|
+
a CycloneDX SBOM at a permanent unauthenticated URL, plus an evidence bundle
|
|
92
|
+
from `npm run evidence` recording identity, the test run, the SBOM and the
|
|
93
|
+
`kxco-post-quantum` version actually installed rather than the range declared.
|
|
83
94
|
|
|
84
|
-
**Supported versions.** One line moving forward
|
|
95
|
+
**Supported versions.** One line moving forward. Fixes land in the next release.
|
|
85
96
|
|
|
86
|
-
**
|
|
87
|
-
|
|
88
|
-
|
|
97
|
+
**Cost.** No hardware or runtime ceiling. Signing is one ML-DSA-65 operation per
|
|
98
|
+
action; the practical limits are the scope caps themselves, which are policy
|
|
99
|
+
rather than performance.
|
|
89
100
|
|
|
90
|
-
**
|
|
91
|
-
|
|
92
|
-
|
|
101
|
+
**Connection.** `relay.kxco.ai`, which negotiates the hybrid key exchange group
|
|
102
|
+
`X25519MLKEM768` under TLS 1.3. Measured 7 September 2026 with OpenSSL 3.5.6,
|
|
103
|
+
and reproducible:
|
|
93
104
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
has no enforced scope.
|
|
105
|
+
```
|
|
106
|
+
echo | openssl s_client -connect relay.kxco.ai:443 -servername relay.kxco.ai \
|
|
107
|
+
-groups X25519MLKEM768 -tls1_3 2>&1 | grep "Negotiated TLS1.3 group"
|
|
108
|
+
```
|
|
99
109
|
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
The intent is signed with ML-DSA-65 before it is sent and verified on chain
|
|
111
|
+
after it arrives, so the transport carries the intent rather than securing it.
|
|
102
112
|
|
|
103
113
|
## Correcting this document
|
|
104
114
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
115
|
+
Every claim here is checkable against `src/` and the README. The TLS measurement
|
|
116
|
+
is reproducible with the command given. If one does not match, that is a defect
|
|
117
|
+
worth reporting through the repository's issues.
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.1.1
|
|
4
|
+
|
|
5
|
+
checkScope denies an amount, spentToday or limit that is not a finite number,
|
|
6
|
+
and any section, limit or `enabled` switch of the wrong type, naming it.
|
|
7
|
+
validateScope requires positive integer limits, string recipients and boolean
|
|
8
|
+
`enabled` switches. verify() and import() validate the scope they return, and
|
|
9
|
+
verify() treats an expiresAt that is not a date as expired. A scope key named
|
|
10
|
+
`__proto__` is refused.
|
|
11
|
+
|
|
12
|
+
Credential text fields and relay intent header fields must be one line of
|
|
13
|
+
well-formed text, and AgentChainClient refuses a relay that is not a string.
|
|
14
|
+
The typings now say that an empty purposes list allows no purpose.
|
|
15
|
+
|
|
16
|
+
**The npm page leads with what the package proves.** The first screen now says
|
|
17
|
+
who answers for an agent, what it may do and how both ends enforce it, the
|
|
18
|
+
evidence underneath and the migration dates set by NIST, Executive Order 14412,
|
|
19
|
+
OMB M-26-15 and the UK NCSC.
|
|
20
|
+
|
|
21
|
+
A family table maps every KXCO package to the job it does, and a new For
|
|
22
|
+
institutions section sets out the operated services and how to reach us. The
|
|
23
|
+
evidence documents are unchanged and linked from the page.
|
|
24
|
+
|
|
25
|
+
The scope manifest example uses a complete recipient address, so it validates
|
|
26
|
+
as written, and the page gains Security, Release integrity and License sections.
|
|
27
|
+
|
|
28
|
+
A NOTICE file names the copyright owner, Knightsbridge Financial Ltd, trading
|
|
29
|
+
as KXCO, and ships in the package, so anyone who redistributes it carries the
|
|
30
|
+
attribution, as section 4(d) of the Apache License requires.
|
|
31
|
+
|
|
32
|
+
## 1.1.0
|
|
33
|
+
|
|
34
|
+
**`checkScope()` enforces the scope locally, before the relay sees the action.**
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { checkScope } from 'kxco-pq-agent'
|
|
38
|
+
|
|
39
|
+
const decision = checkScope(agent.scope, {
|
|
40
|
+
type: 'payment', amount: 4500, spentToday: 12000, recipient: '0xAbC…',
|
|
41
|
+
})
|
|
42
|
+
// { allowed: false,
|
|
43
|
+
// reason: "amount 4500 would take today's total to 16500, past maxPerDay 15000",
|
|
44
|
+
// checked: ['payments.enabled', 'payments.maxPerTransaction', 'payments.maxPerDay'] }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The relay's enforcement is unchanged and is still the one that binds, because it
|
|
48
|
+
sits behind the agent where a compromised agent cannot reach it. This runs in
|
|
49
|
+
front, on the same signed scope, and earns its place three ways: it refuses
|
|
50
|
+
offline, so an agent that cannot reach the relay still knows what it may not do;
|
|
51
|
+
it refuses without spending a round trip; and it names the limit that stopped
|
|
52
|
+
it, which a remote refusal cannot do as precisely.
|
|
53
|
+
|
|
54
|
+
**Fails closed throughout.** A capability the scope does not grant is denied. An
|
|
55
|
+
action type it does not recognise is denied. A configured limit that cannot be
|
|
56
|
+
judged from the inputs given is denied rather than skipped — pass a scope with
|
|
57
|
+
`maxPerDay` set and no `spentToday`, and the answer is a refusal that says which
|
|
58
|
+
input is missing, not a pass that quietly skipped a control.
|
|
59
|
+
|
|
60
|
+
`checked` reports every limit the decision actually evaluated, in the order
|
|
61
|
+
applied, so a caller can show the control ran rather than assert it did.
|
|
62
|
+
|
|
63
|
+
Covers payments (`maxPerTransaction`, `maxPerDay`, `allowedRecipients`, with EVM
|
|
64
|
+
recipients matched case-insensitively), attestation purposes, `auditLog` and
|
|
65
|
+
`credentials`. Eleven tests.
|
|
66
|
+
|
|
67
|
+
**ASSESSMENT.md rewritten** to lead with the containment properties — an agent
|
|
68
|
+
cannot widen its own authority, cannot mint another agent, and always traces to
|
|
69
|
+
a KYC-verified institution — and to record that enforcement now runs at both
|
|
70
|
+
ends.
|
package/LICENSE
CHANGED
|
@@ -31,4 +31,4 @@ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
|
31
31
|
|
|
32
32
|
8. Limitation of Liability. IN NO EVENT SHALL ANY CONTRIBUTOR BE LIABLE FOR ANY DAMAGES ARISING FROM THIS LICENSE OR THE USE OF THE WORK.
|
|
33
33
|
|
|
34
|
-
Copyright 2026
|
|
34
|
+
Copyright 2026 Knightsbridge Financial Ltd, trading as KXCO
|
package/NOTICE
ADDED
package/README.md
CHANGED
|
@@ -1,45 +1,36 @@
|
|
|
1
1
|
# kxco-pq-agent
|
|
2
2
|
|
|
3
|
-
Post-quantum identity for AI agents and
|
|
3
|
+
**Post-quantum identity for AI agents: an ML-DSA-65 key, a locked scope and the signature of the KYC-verified institution that answers for the agent.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/kxco-pq-agent)
|
|
6
|
+
[](https://www.npmjs.com/package/kxco-pq-agent)
|
|
7
|
+
[](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md)
|
|
8
|
+
[](https://www.npmjs.com/package/kxco-pq-agent)
|
|
9
|
+
[](https://socket.dev/npm/package/kxco-pq-agent)
|
|
10
|
+
[](./LICENSE)
|
|
11
|
+
[](https://nodejs.org)
|
|
6
12
|
|
|
7
|
-
|
|
13
|
+
AI systems cannot pass KYC. An LLM, robot, IoT device, or daemon has no legal standing to authenticate itself to a regulated network. This package solves that with a delegation model: a KYC-verified institution sponsors the agent by signing its ML-DSA-65 public key alongside a locked capability scope. The agent then signs its own relay operations independently, presenting the sponsor's credential as proof of authority. The KXCO relay validates both signatures before accepting any intent, so the institution's approval is cryptographically bound to every action the agent takes.
|
|
8
14
|
|
|
9
|
-
Every
|
|
10
|
-
|
|
11
|
-
- **
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
- **
|
|
16
|
-
at `releases/download/v<version>/sbom.cyclonedx.json`, a permanent
|
|
17
|
-
unauthenticated URL. Not an expiring build artifact.
|
|
18
|
-
- **Pinned where it matters.** Third-party dependencies are pinned to exact
|
|
19
|
-
versions, never ranges, so the code that performs the cryptography cannot
|
|
20
|
-
change without a release. Sibling `kxco-*` packages sit on caret ranges
|
|
21
|
-
deliberately: it means a correctness fix in the base package reaches you
|
|
22
|
-
without a release of every package above it. That is not theoretical. When
|
|
23
|
-
`@noble/post-quantum` 0.7.1 was found to fail NIST SLH-DSA verification
|
|
24
|
-
vectors, the revert in the base package propagated here on the next install.
|
|
25
|
-
Every GitHub Action is pinned by 40-character commit SHA.
|
|
26
|
-
- **Conformance underneath.** The cryptography comes from
|
|
27
|
-
[`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), which
|
|
28
|
-
is run against **2,103 NIST ACVP vectors: 1,793 passed, 0 failed, 310 skipped** and a **225-check
|
|
29
|
-
cross-implementation interoperability matrix** against liboqs, Bouncy Castle
|
|
30
|
-
and two pure-Python implementations, in both directions and with negative
|
|
31
|
-
controls. Its published tarball also rebuilds bit-for-bit from its own tag,
|
|
32
|
-
verified in CI on every run.
|
|
33
|
-
|
|
34
|
-
## The problem it solves
|
|
35
|
-
|
|
36
|
-
AI systems cannot pass KYC. An LLM, robot, IoT device, or daemon has no legal standing to authenticate itself to a regulated network. This package solves that with a delegation model: a KYC-verified institution sponsors the agent by signing its ML-DSA-65 public key alongside a locked capability scope. The agent then signs its own relay operations independently, presenting the sponsor's credential as proof of authority. The KXCO relay validates both signatures before accepting any intent — the institution's approval is cryptographically bound to every action the agent takes.
|
|
15
|
+
- **Every agent answers to a named institution.** A KYC-verified sponsor signs the agent's ML-DSA-65 key and scope at issuance, and can record the key and a hash of the scope on Armature L1 through `kxco-pq-chain`, so the question of who authorised an action has a legal entity for an answer.
|
|
16
|
+
- **Authority fixed at issuance.** The sponsor signs the capability scope into the credential, so the agent cannot widen it: new permissions mean the sponsor revokes and re-issues.
|
|
17
|
+
- **Enforced at both ends.** The relay checks the signed scope behind the agent, and `checkScope()` checks it in front: offline, with no round trip, naming the limit that stopped it, and failing closed.
|
|
18
|
+
- **Every action signed and bound.** Each relay intent carries the agent's own ML-DSA-65 signature over the operation and its payload, a fresh nonce, a timestamp and the hash of the sponsor's credential.
|
|
19
|
+
- **Any autonomous system, always with an end date.** LLMs, robots, IoT devices and daemons each get a typed identity, and expiry is mandatory, so a short-lived task never leaves a long-lived key behind.
|
|
20
|
+
- **Proven underneath.** 1,793 NIST ACVP vectors passed, 0 failed, and 225 interoperability checks against liboqs, Bouncy Castle and the Python reference implementations, 0 failed, in [`kxco-post-quantum`](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md).
|
|
21
|
+
- **A supply chain you can check.** SLSA provenance and a CycloneDX SBOM on every release since 1.0.7, third-party dependencies pinned to exact versions, and every GitHub Action pinned by commit SHA.
|
|
37
22
|
|
|
38
|
-
|
|
23
|
+
**The migration has dates.**
|
|
24
|
+
|
|
25
|
+
- **NIST** published [FIPS 203](https://csrc.nist.gov/pubs/fips/203/final), [FIPS 204](https://csrc.nist.gov/pubs/fips/204/final) and [FIPS 205](https://csrc.nist.gov/pubs/fips/205/final) in August 2024.
|
|
26
|
+
- **United States:** [Executive Order 14412](https://www.federalregister.gov/documents/2026/06/25/2026-12909/securing-the-nation-against-advanced-cryptographic-attacks), signed on 22 June 2026, moves federal high-value and high-impact systems to post-quantum key establishment by 31 December 2030 and to post-quantum signatures by 31 December 2031. [OMB M-26-15](https://www.whitehouse.gov/wp-content/uploads/2026/06/M-26-15-Execution-of-the-Migration-to-Post-Quantum-Cryptography.pdf) requires PQC-agile libraries for all new applications.
|
|
27
|
+
- **United Kingdom:** the [NCSC](https://www.ncsc.gov.uk/guidance/pqc-migration-timelines) sets 2028, 2031 and 2035 as its migration milestones.
|
|
28
|
+
|
|
29
|
+
[Quick start](#quick-start) · [Scope manifest](#scope-manifest) · [The containment model](#the-containment-model) · [For institutions](#for-institutions) · [Assessment notes](./ASSESSMENT.md) · [Changelog](./CHANGELOG.md) · [kxco.ai](https://kxco.ai)
|
|
39
30
|
|
|
40
31
|
## When to use this
|
|
41
32
|
|
|
42
|
-
Use this package when deploying LLMs, robots, IoT devices, or daemons that need to perform on-chain operations
|
|
33
|
+
Use this package when deploying LLMs, robots, IoT devices, or daemons that need to perform on-chain operations (payments, attestation anchoring, audit checkpointing) with verifiable, auditable scope controlled by a licensed institution.
|
|
43
34
|
|
|
44
35
|
---
|
|
45
36
|
|
|
@@ -89,10 +80,10 @@ The scope is signed by the sponsor at issuance and cannot be changed. To update
|
|
|
89
80
|
```js
|
|
90
81
|
scope: {
|
|
91
82
|
payments: {
|
|
92
|
-
maxPerTransaction: 5000, // maximum ARMR per transfer (positive
|
|
83
|
+
maxPerTransaction: 5000, // maximum ARMR per transfer (positive integer)
|
|
93
84
|
maxPerDay: 50000, // rolling daily cap across all transfers
|
|
94
85
|
allowedRecipients: [ // EVM address (0x + 40 hex) or KXCO kid (16 hex chars)
|
|
95
|
-
'
|
|
86
|
+
'0x1234567890abcdef1234567890abcdef12345678',
|
|
96
87
|
'aa29f37ab7f4b2cf',
|
|
97
88
|
],
|
|
98
89
|
},
|
|
@@ -108,6 +99,28 @@ All fields are optional. Omit a section entirely to deny that capability. The sc
|
|
|
108
99
|
|
|
109
100
|
---
|
|
110
101
|
|
|
102
|
+
## For institutions
|
|
103
|
+
|
|
104
|
+
The cryptography is free under Apache-2.0, works offline and needs nothing from
|
|
105
|
+
KXCO, now or in ten years. What KXCO sells is the part that has to be operated:
|
|
106
|
+
an answer about the present.
|
|
107
|
+
|
|
108
|
+
| Service | What you get |
|
|
109
|
+
|---|---|
|
|
110
|
+
| Hosted key registry | Whether a key is active, revoked or rotated, answered at verification time |
|
|
111
|
+
| Meta-transaction relay | KXCO validates your signed intent, pays the gas and submits it, so you never hold a token or run a node |
|
|
112
|
+
| On-chain anchoring | A timestamp on Armature L1 that the chain itself has verified |
|
|
113
|
+
| Live revocation | `anchored+live` verification, which confirms the signing key is still trusted now |
|
|
114
|
+
| Support and SLA | Availability commitments, an escalation path and a named contact |
|
|
115
|
+
|
|
116
|
+
Priced in USD, per seat, per year. No tokens, no nodes and no wallets. The line
|
|
117
|
+
between free and paid is set out in
|
|
118
|
+
[LICENCE-PRODUCT.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/LICENCE-PRODUCT.md).
|
|
119
|
+
|
|
120
|
+
**Talk to us: [admin@kxco.ai](mailto:admin@kxco.ai)** · [kxco.ai](https://kxco.ai)
|
|
121
|
+
|
|
122
|
+
---
|
|
123
|
+
|
|
111
124
|
## API
|
|
112
125
|
|
|
113
126
|
### KxcoAgentIdentity
|
|
@@ -119,9 +132,9 @@ Generates an ML-DSA-65 keypair for the agent and has the sponsor sign the creden
|
|
|
119
132
|
| Option | Type | Required | Description |
|
|
120
133
|
|--------|------|----------|-------------|
|
|
121
134
|
| `sponsor` | `{ kid: string, sign(msg: Uint8Array): Promise<Uint8Array> }` | Yes | The sponsoring KxcoIdentity |
|
|
122
|
-
| `label` | `string` | Yes | Human-readable name for this agent |
|
|
135
|
+
| `label` | `string` | Yes | Human-readable name for this agent, one line of text |
|
|
123
136
|
| `agentType` | `'llm' \| 'robot' \| 'iot' \| 'process'` | Yes | Category of the agent |
|
|
124
|
-
| `model` | `string` | No | Model, firmware, or version identifier |
|
|
137
|
+
| `model` | `string` | No | Model, firmware, or version identifier, one line of text |
|
|
125
138
|
| `scope` | `object` | Yes | Capability manifest (see above) |
|
|
126
139
|
| `expiresIn` | `string \| number` | Yes | Duration: `'30d'`, `'1y'`, or seconds as a number |
|
|
127
140
|
| `chain` | `KxcoChain` | No | If provided, registers the credential on-chain at issuance |
|
|
@@ -132,7 +145,7 @@ Returns a relay client that automatically attaches the agent's credential and si
|
|
|
132
145
|
|
|
133
146
|
| Option | Type | Default | Description |
|
|
134
147
|
|--------|------|---------|-------------|
|
|
135
|
-
| `relay` | `string` |
|
|
148
|
+
| `relay` | `string` | none | Relay base URL, e.g. `'https://relay.kxco.ai'` |
|
|
136
149
|
| `opts.timeout` | `number` | `10000` | Request timeout in milliseconds |
|
|
137
150
|
|
|
138
151
|
**`agent.sign(message)` → `Promise<Uint8Array>`**
|
|
@@ -176,6 +189,42 @@ Anchors an audit log checkpoint on-chain. Requires `scope.auditLog: true`.
|
|
|
176
189
|
|
|
177
190
|
Submits a payment intent. `to` is an EVM address or KXCO kid. `amount` is in ARMR. The relay enforces `allowedRecipients`, `maxPerTransaction`, and `maxPerDay` from the scope.
|
|
178
191
|
|
|
192
|
+
### `checkScope(scope, action)`
|
|
193
|
+
|
|
194
|
+
Decide whether a scope permits an action, before attempting it.
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
import { checkScope } from 'kxco-pq-agent'
|
|
198
|
+
|
|
199
|
+
// agent.scope grants payments with maxPerTransaction 5000 and maxPerDay 15000
|
|
200
|
+
checkScope(agent.scope, {
|
|
201
|
+
type: 'payment', amount: 4500, spentToday: 12000, recipient: '0xAbC…',
|
|
202
|
+
})
|
|
203
|
+
// { allowed: false,
|
|
204
|
+
// reason: "amount 4500 would take today's total to 16500, past maxPerDay 15000",
|
|
205
|
+
// checked: ['payments.enabled', 'payments.maxPerTransaction', 'payments.maxPerDay'] }
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The relay enforces the same signed scope behind the agent, and that enforcement
|
|
209
|
+
is the one that binds. This runs in front of it: it refuses offline, refuses
|
|
210
|
+
without spending a round trip, and names the limit that stopped it.
|
|
211
|
+
|
|
212
|
+
| Action | Fields | Checked against |
|
|
213
|
+
|---|---|---|
|
|
214
|
+
| `payment` | `amount`, `recipient`, `spentToday` | `maxPerTransaction`, `maxPerDay`, `allowedRecipients` |
|
|
215
|
+
| `attestation` | `purpose` | `attestations.purposes` |
|
|
216
|
+
| `auditLog` | none | `auditLog` |
|
|
217
|
+
| `credentials` | none | `credentials` |
|
|
218
|
+
|
|
219
|
+
Returns `{ allowed, reason?, checked }`. `checked` lists every limit the
|
|
220
|
+
decision actually evaluated, so a caller can show the control ran.
|
|
221
|
+
|
|
222
|
+
It fails closed: a capability the scope does not grant is denied, an action type
|
|
223
|
+
it does not recognise is denied, and a configured limit that cannot be judged
|
|
224
|
+
from the inputs given is denied rather than skipped. Set `maxPerDay` and omit
|
|
225
|
+
`spentToday` and the answer is a refusal naming the missing input, never a pass
|
|
226
|
+
that skipped the cap.
|
|
227
|
+
|
|
179
228
|
---
|
|
180
229
|
|
|
181
230
|
## Agent types
|
|
@@ -209,18 +258,62 @@ attributable to a legal entity when a supervisor asks who authorised it.
|
|
|
209
258
|
|
|
210
259
|
---
|
|
211
260
|
|
|
212
|
-
##
|
|
261
|
+
## The KXCO post-quantum family
|
|
262
|
+
|
|
263
|
+
This gives an agent an identity a verified institution answers for. The rest of the family covers the jobs around it:
|
|
264
|
+
|
|
265
|
+
| You need to | Install |
|
|
266
|
+
|---|---|
|
|
267
|
+
| Put the whole stack in one install | [`kxco-pq`](https://www.npmjs.com/package/kxco-pq) |
|
|
268
|
+
| Use ML-DSA, ML-KEM and SLH-DSA directly | [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum) |
|
|
269
|
+
| Keep signing keys on the HSM you already run | [`kxco-pq-hsm`](https://www.npmjs.com/package/kxco-pq-hsm) |
|
|
270
|
+
| Sign a document or record anyone can verify offline | [`kxco-pq-attest`](https://www.npmjs.com/package/kxco-pq-attest) |
|
|
271
|
+
| Keep a tamper-evident audit trail | [`kxco-pq-audit`](https://www.npmjs.com/package/kxco-pq-audit) |
|
|
272
|
+
| Verify a signature in a browser, with no server | [`kxco-verify`](https://www.npmjs.com/package/kxco-verify) |
|
|
273
|
+
| Issue institution identity credentials | [`kxco-pq-sdk`](https://www.npmjs.com/package/kxco-pq-sdk) |
|
|
274
|
+
| Encrypt files and payloads to one or many recipients | [`kxco-pq-vault`](https://www.npmjs.com/package/kxco-pq-vault) |
|
|
275
|
+
| Encrypt Node streams and WebSockets | [`kxco-pq-tls`](https://www.npmjs.com/package/kxco-pq-tls) |
|
|
276
|
+
| Sign and verify webhooks | [`kxco-post-quantum-webhook`](https://www.npmjs.com/package/kxco-post-quantum-webhook) |
|
|
277
|
+
| Give an AI agent an identity a verified institution sponsors | [`kxco-pq-agent`](https://www.npmjs.com/package/kxco-pq-agent) |
|
|
278
|
+
| Have Armature L1 verify a signature in consensus | [`kxco-pq-chain`](https://www.npmjs.com/package/kxco-pq-chain) |
|
|
279
|
+
| Prove an envelope at three levels, offline to on-chain | [`kxco-pq-network`](https://www.npmjs.com/package/kxco-pq-network) |
|
|
280
|
+
| Generate and rotate keys from a terminal | [`kxco-pq-cli`](https://www.npmjs.com/package/kxco-pq-cli) |
|
|
281
|
+
| Find quantum-vulnerable cryptography in a dependency tree | [`kxco-pq-scan`](https://www.npmjs.com/package/kxco-pq-scan) |
|
|
282
|
+
| Fail the build when code reaches past the wrapper | [`eslint-plugin-kxco-pq`](https://www.npmjs.com/package/eslint-plugin-kxco-pq) |
|
|
213
283
|
|
|
214
|
-
|
|
215
|
-
|---------|---------|
|
|
216
|
-
| `kxco-post-quantum` | ML-DSA-65 and ML-KEM primitives |
|
|
217
|
-
| `kxco-pq-sdk` | KxcoIdentity — human and institution identities |
|
|
218
|
-
| `kxco-pq-agent` | This package — machine and agent identities |
|
|
284
|
+
## Release integrity
|
|
219
285
|
|
|
220
|
-
|
|
286
|
+
Every release since 1.0.7 carries a SLSA provenance attestation tying the published tarball to
|
|
287
|
+
the commit and workflow that built it: verify with `npm audit signatures`, or read
|
|
288
|
+
it from `registry.npmjs.org/-/npm/v1/attestations/kxco-pq-agent@<version>`. A CycloneDX
|
|
289
|
+
SBOM is published, from v1.0.7, as a GitHub Release asset at
|
|
290
|
+
`releases/download/v<version>/sbom.cyclonedx.json`, a permanent unauthenticated
|
|
291
|
+
URL. Sibling `kxco-*` packages sit on caret ranges so a correctness fix in the
|
|
292
|
+
base package reaches you on the next install, with no release of every package
|
|
293
|
+
above it.
|
|
221
294
|
|
|
222
|
-
|
|
295
|
+
## Security
|
|
296
|
+
|
|
297
|
+
Agents sign with **ML-DSA-65** (NIST FIPS 204) via [`kxco-post-quantum`](https://www.npmjs.com/package/kxco-post-quantum), running on the OpenSSL 3.5 primitives where the runtime provides them. No custom cryptography.
|
|
298
|
+
|
|
299
|
+
Evidenced, and reproducible on your own machine:
|
|
300
|
+
|
|
301
|
+
- **1,793 NIST ACVP vectors passed, 0 failed** across FIPS 203, 204 and 205, pinned by digest, per [CONFORMANCE.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/CONFORMANCE.md). The other 310 are pairings the library refuses as weaker than the parameter set
|
|
302
|
+
- **225 interoperability checks passed, 0 failed**, against OpenSSL 3.5, liboqs, Bouncy Castle and dilithium-py/kyber-py, in both directions
|
|
303
|
+
- **SLSA provenance** on every release since 1.0.7: verify with `npm audit signatures`
|
|
304
|
+
- **CycloneDX SBOM** published with every release since 1.0.7
|
|
305
|
+
- `npm run evidence` records this package's identity, its own test run, its SBOM and the `kxco-post-quantum` version actually installed
|
|
306
|
+
|
|
307
|
+
Dependency audit history is recorded in [AUDIT.md](https://github.com/KnightsbridgeAIQ/kxco-post-quantum/blob/main/AUDIT.md).
|
|
308
|
+
|
|
309
|
+
The sponsor's signature covers the agent's key id and public key, the sponsor's key id, the agent type, label, model, scope, issue time and expiry, so a credential with any field changed fails `KxcoAgentIdentity.verify()` against the sponsor's key.
|
|
310
|
+
|
|
311
|
+
To report a vulnerability, email **security@kxco.ai**.
|
|
312
|
+
|
|
313
|
+
## License
|
|
314
|
+
|
|
315
|
+
Apache-2.0 © 2026 Knightsbridge Financial Ltd, trading as KXCO. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE).
|
|
223
316
|
|
|
224
|
-
##
|
|
317
|
+
## Maintainers
|
|
225
318
|
|
|
226
|
-
Shayne Heffernan and John Heffernan
|
|
319
|
+
Shayne Heffernan and John Heffernan, [KXCO by Knightsbridge](https://kxco.ai)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kxco-pq-agent",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Post-quantum identity for AI agents
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Post-quantum identity for AI agents: a KYC-verified institution sponsors an ML-DSA-65 key and a locked capability scope, enforced at the relay and offline by checkScope(). For LLMs, robots, IoT devices and daemons, with mandatory expiry.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"post-quantum",
|
|
7
7
|
"pqc",
|
|
@@ -16,7 +16,16 @@
|
|
|
16
16
|
"capability-scope",
|
|
17
17
|
"armature",
|
|
18
18
|
"relay",
|
|
19
|
-
"blockchain"
|
|
19
|
+
"blockchain",
|
|
20
|
+
"pqc-migration",
|
|
21
|
+
"crypto-agility",
|
|
22
|
+
"digital-signature",
|
|
23
|
+
"non-repudiation",
|
|
24
|
+
"agentic-ai",
|
|
25
|
+
"autonomous-agents",
|
|
26
|
+
"llm-agent",
|
|
27
|
+
"kyc",
|
|
28
|
+
"delegated-authority"
|
|
20
29
|
],
|
|
21
30
|
"license": "Apache-2.0",
|
|
22
31
|
"author": "Shayne Heffernan and John Heffernan",
|
|
@@ -47,8 +56,10 @@
|
|
|
47
56
|
}
|
|
48
57
|
},
|
|
49
58
|
"files": [
|
|
59
|
+
"CHANGELOG.md",
|
|
50
60
|
"src",
|
|
51
61
|
"LICENSE",
|
|
62
|
+
"NOTICE",
|
|
52
63
|
"ASSESSMENT.md"
|
|
53
64
|
],
|
|
54
65
|
"engines": {
|
|
@@ -58,11 +69,15 @@
|
|
|
58
69
|
"kxco-post-quantum": "^1.3.0"
|
|
59
70
|
},
|
|
60
71
|
"scripts": {
|
|
61
|
-
"test": "node --test --test-timeout=30000 test/agent.test.js",
|
|
72
|
+
"test": "node --test --test-timeout=30000 test/agent.test.js test/property.test.js",
|
|
62
73
|
"evidence": "node scripts/build-evidence.mjs"
|
|
63
74
|
},
|
|
64
75
|
"funding": "https://kxco.ai",
|
|
65
76
|
"publishConfig": {
|
|
77
|
+
"provenance": true,
|
|
66
78
|
"access": "public"
|
|
79
|
+
},
|
|
80
|
+
"devDependencies": {
|
|
81
|
+
"fast-check": "4.10.2"
|
|
67
82
|
}
|
|
68
83
|
}
|
package/src/agent-client.js
CHANGED
|
@@ -14,15 +14,33 @@ async function sha256Hex(str) {
|
|
|
14
14
|
return Buffer.from(buf).toString('hex')
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// Each header is one line, so a field carrying a line break could pass for the
|
|
18
|
+
// end of one header and the start of the next, and two different intents would
|
|
19
|
+
// sign the same bytes. An unpaired surrogate has no UTF-8 form: TextEncoder
|
|
20
|
+
// writes every one as U+FFFD, so it is refused for the same reason. JSON
|
|
21
|
+
// escapes both inside the payload, so only the header fields need the check.
|
|
22
|
+
const UNSAFE_HEADER = /[\r\n]|\p{Cs}/u
|
|
23
|
+
|
|
24
|
+
function header(name, value) {
|
|
25
|
+
const text = `${value}`
|
|
26
|
+
if (UNSAFE_HEADER.test(text)) {
|
|
27
|
+
throw new KxcoPqAgentError(
|
|
28
|
+
`${name} must not contain a line break or an unpaired surrogate`,
|
|
29
|
+
{ code: 'BAD_ARGUMENT' },
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
return `${name}: ${text}`
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
function buildSigningMessage(operation, agentKid, sponsorKid, nonce, timestamp, credentialHash, payload) {
|
|
18
36
|
return enc.encode([
|
|
19
37
|
'kxco-relay-agent-v1',
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
header('operation', operation),
|
|
39
|
+
header('agentKid', agentKid),
|
|
40
|
+
header('sponsorKid', sponsorKid),
|
|
41
|
+
header('nonce', nonce),
|
|
42
|
+
header('timestamp', timestamp),
|
|
43
|
+
header('credentialHash', credentialHash),
|
|
26
44
|
`payload: ${canonicalize(payload)}`,
|
|
27
45
|
].join('\n'))
|
|
28
46
|
}
|
|
@@ -45,6 +63,7 @@ export class AgentChainClient {
|
|
|
45
63
|
|
|
46
64
|
constructor({ relay, agent, timeout = 10_000 }) {
|
|
47
65
|
if (!relay) throw new KxcoPqAgentError('relay URL is required', { code: 'BAD_CONFIG' })
|
|
66
|
+
if (typeof relay !== 'string') throw new KxcoPqAgentError('relay must be a URL string', { code: 'BAD_CONFIG' })
|
|
48
67
|
if (!agent) throw new KxcoPqAgentError('agent is required', { code: 'BAD_CONFIG' })
|
|
49
68
|
this.#relay = relay.replace(/\/$/, '')
|
|
50
69
|
this.#agent = agent
|
package/src/agent-identity.js
CHANGED
|
@@ -41,6 +41,26 @@ function credentialSigningMsg({ agentKid, agentPublicKey, sponsorKid, agentType,
|
|
|
41
41
|
].join('\n'))
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// The sponsor-signed message is one field per line, so a field carrying a
|
|
45
|
+
// line break could hand part of its text to its neighbour, and two different
|
|
46
|
+
// credentials would sign the same bytes. An unpaired surrogate has no UTF-8
|
|
47
|
+
// form (TextEncoder writes every one as U+FFFD), so it is refused as well.
|
|
48
|
+
const UNSAFE_FIELD = /[\r\n]|\p{Cs}/u
|
|
49
|
+
|
|
50
|
+
// Why the first of these fields cannot go into the message, or null.
|
|
51
|
+
function fieldFault(fields) {
|
|
52
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
53
|
+
let text
|
|
54
|
+
try {
|
|
55
|
+
text = value == null ? '' : `${value}`
|
|
56
|
+
} catch {
|
|
57
|
+
return `${name} cannot be written as text`
|
|
58
|
+
}
|
|
59
|
+
if (UNSAFE_FIELD.test(text)) return `${name} must not contain a line break or an unpaired surrogate`
|
|
60
|
+
}
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
const VALID_AGENT_TYPES = new Set(['llm', 'robot', 'iot', 'process'])
|
|
45
65
|
|
|
46
66
|
export class KxcoAgentIdentity {
|
|
@@ -104,13 +124,20 @@ export class KxcoAgentIdentity {
|
|
|
104
124
|
if (!scope) throw new KxcoPqAgentError('create: scope is required')
|
|
105
125
|
if (expiresIn == null) throw new KxcoPqAgentError('create: expiresIn is required — agents must have an expiry')
|
|
106
126
|
|
|
127
|
+
const fault = fieldFault({ label, model, sponsorKid: sponsor.kid })
|
|
128
|
+
if (fault) throw new KxcoPqAgentError(`create: ${fault}`)
|
|
129
|
+
|
|
107
130
|
validateScope(scope)
|
|
108
131
|
|
|
109
132
|
const keypair = mlDsa.ml_dsa65.keygen()
|
|
110
133
|
const agentKid = fingerprint(keypair.publicKey)
|
|
111
134
|
const agentPubB64 = b64url(keypair.publicKey)
|
|
112
135
|
const issuedAt = new Date().toISOString()
|
|
113
|
-
const
|
|
136
|
+
const expiry = new Date(Date.now() + parseDuration(expiresIn))
|
|
137
|
+
if (Number.isNaN(expiry.getTime())) {
|
|
138
|
+
throw new KxcoPqAgentError(`create: expiresIn '${expiresIn}' does not give a representable expiry date`)
|
|
139
|
+
}
|
|
140
|
+
const expiresAt = expiry.toISOString()
|
|
114
141
|
|
|
115
142
|
const sigMsg = credentialSigningMsg({
|
|
116
143
|
agentKid,
|
|
@@ -204,6 +231,9 @@ export class KxcoAgentIdentity {
|
|
|
204
231
|
if (!exported || exported['kxco-agent-identity'] !== IDENTITY_VERSION) {
|
|
205
232
|
throw new KxcoPqAgentError('import: invalid or unsupported agent identity format')
|
|
206
233
|
}
|
|
234
|
+
// The restored agent acts on this scope, so it is held to the rules
|
|
235
|
+
// create() applied.
|
|
236
|
+
validateScope(exported.scope)
|
|
207
237
|
return new KxcoAgentIdentity({
|
|
208
238
|
kid: exported.kid,
|
|
209
239
|
keypair: { secretKey: fromB64url(exported.secretKey), publicKey: fromB64url(exported.publicKey) },
|
|
@@ -254,7 +284,24 @@ export class KxcoAgentIdentity {
|
|
|
254
284
|
return { valid: false, error: 'malformed credential — missing required fields' }
|
|
255
285
|
}
|
|
256
286
|
|
|
257
|
-
|
|
287
|
+
const fault = fieldFault({ agentKid, agentPublicKey, sponsorKid, agentType, label, model, issuedAt, expiresAt })
|
|
288
|
+
if (fault) return { valid: false, error: `malformed credential: ${fault}` }
|
|
289
|
+
|
|
290
|
+
// The scope is returned for the caller to act on, so it is held to the
|
|
291
|
+
// rules create() applied. That also means it has a JCS form to check the
|
|
292
|
+
// signature over.
|
|
293
|
+
try {
|
|
294
|
+
validateScope(scope)
|
|
295
|
+
} catch (err) {
|
|
296
|
+
return { valid: false, error: `malformed credential: ${err.message}` }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// An expiry that is not a date cannot be shown to lie in the future.
|
|
300
|
+
const expiry = new Date(expiresAt).getTime()
|
|
301
|
+
if (Number.isNaN(expiry)) {
|
|
302
|
+
return { valid: false, error: 'agent credential has no valid expiry, so it is treated as expired' }
|
|
303
|
+
}
|
|
304
|
+
if (expiry < Date.now()) {
|
|
258
305
|
return { valid: false, error: 'agent credential has expired' }
|
|
259
306
|
}
|
|
260
307
|
|
package/src/index.d.ts
CHANGED
|
@@ -24,7 +24,10 @@ export interface PaymentScope {
|
|
|
24
24
|
|
|
25
25
|
export interface AttestationScope {
|
|
26
26
|
enabled: boolean
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* Allowed purposes. Empty array = no purpose is allowed (checkScope fails
|
|
29
|
+
* closed). Absent / undefined = any purpose.
|
|
30
|
+
*/
|
|
28
31
|
purposes?: string[]
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -38,6 +41,30 @@ export interface AgentScope {
|
|
|
38
41
|
export function validateScope(scope: AgentScope): AgentScope
|
|
39
42
|
export function hashScope(scope: AgentScope): Promise<string>
|
|
40
43
|
|
|
44
|
+
export type ScopeAction =
|
|
45
|
+
| { type: 'payment'; amount: number; recipient?: string; spentToday?: number }
|
|
46
|
+
| { type: 'attestation'; purpose?: string }
|
|
47
|
+
| { type: 'auditLog' }
|
|
48
|
+
| { type: 'credentials' }
|
|
49
|
+
|
|
50
|
+
export interface ScopeDecision {
|
|
51
|
+
allowed: boolean
|
|
52
|
+
/** Why the action was refused. Absent when allowed. */
|
|
53
|
+
reason?: string
|
|
54
|
+
/** Every limit the decision actually evaluated, in the order applied. */
|
|
55
|
+
checked: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether a scope permits an action, before it is attempted.
|
|
60
|
+
*
|
|
61
|
+
* Runs in front of the relay's own enforcement, on the same signed scope: it
|
|
62
|
+
* refuses offline, refuses without spending a round trip, and names the limit
|
|
63
|
+
* that stopped it. Fails closed on an ungranted capability, an unknown action
|
|
64
|
+
* type, and a configured limit that cannot be evaluated from the inputs given.
|
|
65
|
+
*/
|
|
66
|
+
export function checkScope(scope: AgentScope, action: ScopeAction): ScopeDecision
|
|
67
|
+
|
|
41
68
|
// ── Credential envelope ───────────────────────────────────────────────────────
|
|
42
69
|
|
|
43
70
|
export interface AgentCredential {
|
package/src/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { KxcoAgentIdentity } from './agent-identity.js'
|
|
2
2
|
export { AgentChainClient } from './agent-client.js'
|
|
3
3
|
export { KxcoPqAgentError } from './errors.js'
|
|
4
|
-
export { validateScope, hashScope } from './scope.js'
|
|
4
|
+
export { validateScope, checkScope, hashScope } from './scope.js'
|
package/src/jcs.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// RFC 8785 JSON Canonicalization Scheme (JCS), subset.
|
|
2
|
-
// Copied from kxco-pq-cli/src/jcs.js
|
|
2
|
+
// Copied from kxco-pq-cli/src/jcs.js, with two differences. It throws
|
|
3
|
+
// KxcoPqAgentError, and it refuses an object key named "__proto__" (see walk()).
|
|
4
|
+
// Otherwise its output must not change: the relay recomputes these bytes with
|
|
5
|
+
// the same algorithm to check the sponsor's signature and the scope hash.
|
|
6
|
+
|
|
7
|
+
import { KxcoPqAgentError } from './errors.js'
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Canonicalize a JSON-serializable value per (a subset of) RFC 8785.
|
|
@@ -15,14 +20,19 @@ function walk(v) {
|
|
|
15
20
|
if (typeof v === 'boolean') return v
|
|
16
21
|
if (typeof v === 'string') return v
|
|
17
22
|
if (typeof v === 'number') {
|
|
18
|
-
if (!Number.isFinite(v)) throw new
|
|
19
|
-
if (!Number.isInteger(v)) throw new
|
|
23
|
+
if (!Number.isFinite(v)) throw new KxcoPqAgentError('JCS: non-finite numbers are not representable in JSON')
|
|
24
|
+
if (!Number.isInteger(v)) throw new KxcoPqAgentError('JCS subset: floats are not supported')
|
|
20
25
|
return v
|
|
21
26
|
}
|
|
22
27
|
if (Array.isArray(v)) return v.map(walk)
|
|
23
28
|
if (v && typeof v === 'object') {
|
|
24
29
|
const out = {}
|
|
25
30
|
for (const k of Object.keys(v).sort()) {
|
|
31
|
+
// Assigning "__proto__" on a plain object sets its prototype rather than
|
|
32
|
+
// adding a key, so the member would be left out of the signed bytes. It
|
|
33
|
+
// is refused, not serialised, so that the bytes stay the ones the relay
|
|
34
|
+
// computes.
|
|
35
|
+
if (k === '__proto__') throw new KxcoPqAgentError('JCS: an object key named "__proto__" is not supported')
|
|
26
36
|
const child = walk(v[k])
|
|
27
37
|
if (child === undefined) continue
|
|
28
38
|
out[k] = child
|
|
@@ -30,5 +40,5 @@ function walk(v) {
|
|
|
30
40
|
return out
|
|
31
41
|
}
|
|
32
42
|
if (v === undefined) return undefined
|
|
33
|
-
throw new
|
|
43
|
+
throw new KxcoPqAgentError(`JCS: unsupported value of type ${typeof v}`)
|
|
34
44
|
}
|
package/src/scope.js
CHANGED
|
@@ -8,6 +8,14 @@ function validRecipient(s) {
|
|
|
8
8
|
return EVM_RE.test(s) || KID_RE.test(s)
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// An `enabled` switch is either absent or a boolean. Only `false` switches a
|
|
12
|
+
// section off, so any other value would otherwise count as on.
|
|
13
|
+
function checkEnabled(name, section) {
|
|
14
|
+
if (section && typeof section === 'object' && section.enabled !== undefined && typeof section.enabled !== 'boolean') {
|
|
15
|
+
throw new KxcoPqAgentError(`scope.${name}.enabled must be a boolean`)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
/**
|
|
12
20
|
* Validate a scope object. Throws KxcoPqAgentError on any violation.
|
|
13
21
|
* Returns the scope unchanged.
|
|
@@ -23,17 +31,21 @@ export function validateScope(scope) {
|
|
|
23
31
|
if (typeof payments !== 'object' || Array.isArray(payments)) {
|
|
24
32
|
throw new KxcoPqAgentError('scope.payments must be an object')
|
|
25
33
|
}
|
|
34
|
+
checkEnabled('payments', payments)
|
|
26
35
|
if (payments.enabled !== false) {
|
|
27
36
|
const { maxPerTransaction: mpt, maxPerDay: mpd, allowedRecipients: ar } = payments
|
|
28
37
|
|
|
38
|
+
// Whole numbers only: create() signs the JCS form of the scope, and the
|
|
39
|
+
// JCS subset here has no fractions. Number.isInteger also refuses NaN
|
|
40
|
+
// and Infinity, which no limit comparison can use.
|
|
29
41
|
if (mpt !== undefined) {
|
|
30
|
-
if (
|
|
31
|
-
throw new KxcoPqAgentError('scope.payments.maxPerTransaction must be a positive
|
|
42
|
+
if (!Number.isInteger(mpt) || mpt <= 0) {
|
|
43
|
+
throw new KxcoPqAgentError('scope.payments.maxPerTransaction must be a positive integer')
|
|
32
44
|
}
|
|
33
45
|
}
|
|
34
46
|
if (mpd !== undefined) {
|
|
35
|
-
if (
|
|
36
|
-
throw new KxcoPqAgentError('scope.payments.maxPerDay must be a positive
|
|
47
|
+
if (!Number.isInteger(mpd) || mpd <= 0) {
|
|
48
|
+
throw new KxcoPqAgentError('scope.payments.maxPerDay must be a positive integer')
|
|
37
49
|
}
|
|
38
50
|
}
|
|
39
51
|
if (mpt !== undefined && mpd !== undefined && mpt > mpd) {
|
|
@@ -44,6 +56,9 @@ export function validateScope(scope) {
|
|
|
44
56
|
throw new KxcoPqAgentError('scope.payments.allowedRecipients must be an array')
|
|
45
57
|
}
|
|
46
58
|
for (const r of ar) {
|
|
59
|
+
if (typeof r !== 'string') {
|
|
60
|
+
throw new KxcoPqAgentError(`each entry in scope.payments.allowedRecipients must be a string, not ${r === null ? 'null' : typeof r}`)
|
|
61
|
+
}
|
|
47
62
|
if (!validRecipient(r)) {
|
|
48
63
|
throw new KxcoPqAgentError(
|
|
49
64
|
`invalid recipient '${r}' — must be an EVM address (0x + 40 hex) or KXCO kid (16 lowercase hex chars)`
|
|
@@ -58,6 +73,7 @@ export function validateScope(scope) {
|
|
|
58
73
|
if (typeof attestations !== 'object' || Array.isArray(attestations)) {
|
|
59
74
|
throw new KxcoPqAgentError('scope.attestations must be an object')
|
|
60
75
|
}
|
|
76
|
+
checkEnabled('attestations', attestations)
|
|
61
77
|
if (attestations.enabled !== false && attestations.purposes !== undefined) {
|
|
62
78
|
if (!Array.isArray(attestations.purposes)) {
|
|
63
79
|
throw new KxcoPqAgentError('scope.attestations.purposes must be an array of strings')
|
|
@@ -77,10 +93,176 @@ export function validateScope(scope) {
|
|
|
77
93
|
if (credentials != null && typeof credentials !== 'boolean' && (typeof credentials !== 'object' || Array.isArray(credentials))) {
|
|
78
94
|
throw new KxcoPqAgentError('scope.credentials must be a boolean or object')
|
|
79
95
|
}
|
|
96
|
+
checkEnabled('auditLog', auditLog)
|
|
97
|
+
checkEnabled('credentials', credentials)
|
|
98
|
+
|
|
99
|
+
// create() signs the JCS form, so a scope that has none (a fraction
|
|
100
|
+
// anywhere in it, or a key JCS refuses) is refused here, before signing.
|
|
101
|
+
canonicalize(scope)
|
|
80
102
|
|
|
81
103
|
return scope
|
|
82
104
|
}
|
|
83
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Decide whether a scope permits an action, before it is attempted.
|
|
108
|
+
*
|
|
109
|
+
* The relay enforces scope too, and that enforcement is the one that binds: it
|
|
110
|
+
* sits behind the agent, so a compromised agent cannot talk its way past it.
|
|
111
|
+
* This check runs in front, on the same signed scope, and it earns its place
|
|
112
|
+
* three ways. It refuses offline, so an agent that cannot reach the relay still
|
|
113
|
+
* knows what it may not do. It refuses immediately, without spending a network
|
|
114
|
+
* round trip to be told no. And it names the limit that stopped it, which a
|
|
115
|
+
* remote refusal cannot do as precisely.
|
|
116
|
+
*
|
|
117
|
+
* Fails closed throughout. A capability the scope does not grant is denied, an
|
|
118
|
+
* action type it does not recognise is denied, and a configured limit that
|
|
119
|
+
* cannot be evaluated from the inputs given is denied rather than skipped.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} scope — the signed capability manifest
|
|
122
|
+
* @param {object} action
|
|
123
|
+
* @param {'payment'|'attestation'|'auditLog'|'credentials'} action.type
|
|
124
|
+
* @param {number} [action.amount] — payment only, ARMR
|
|
125
|
+
* @param {string} [action.recipient] — payment only, EVM address or kid
|
|
126
|
+
* @param {number} [action.spentToday] — payment only, required when
|
|
127
|
+
* `payments.maxPerDay` is set, because a day cap cannot be judged from one
|
|
128
|
+
* transaction
|
|
129
|
+
* @param {string} [action.purpose] — attestation only
|
|
130
|
+
* @returns {{ allowed: boolean, reason?: string, checked: string[] }}
|
|
131
|
+
*/
|
|
132
|
+
export function checkScope(scope, action) {
|
|
133
|
+
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
|
|
134
|
+
throw new KxcoPqAgentError('checkScope: scope must be a plain object')
|
|
135
|
+
}
|
|
136
|
+
if (!action || typeof action !== 'object' || Array.isArray(action)) {
|
|
137
|
+
throw new KxcoPqAgentError('checkScope: action must be a plain object')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const checked = []
|
|
141
|
+
const deny = (reason) => ({ allowed: false, reason, checked })
|
|
142
|
+
const allow = () => ({ allowed: true, checked })
|
|
143
|
+
|
|
144
|
+
const granted = (section) =>
|
|
145
|
+
section != null && section !== false && section.enabled !== false
|
|
146
|
+
|
|
147
|
+
// A section or limit of the wrong type cannot be judged, so it is denied,
|
|
148
|
+
// naming it, rather than read loosely. validateScope refuses the same shapes;
|
|
149
|
+
// these checks cover a scope that never went through it.
|
|
150
|
+
const sectionFault = (name, section, booleanAllowed) => {
|
|
151
|
+
if (section == null || section === false) return null
|
|
152
|
+
if (section === true && booleanAllowed) return null
|
|
153
|
+
if (typeof section !== 'object' || Array.isArray(section)) {
|
|
154
|
+
return `scope.${name} must be ${booleanAllowed ? 'a boolean or an object' : 'an object'}`
|
|
155
|
+
}
|
|
156
|
+
if (section.enabled !== undefined && typeof section.enabled !== 'boolean') {
|
|
157
|
+
return `scope.${name}.enabled must be a boolean`
|
|
158
|
+
}
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
const isLimit = (v) => Number.isFinite(v) && v > 0
|
|
162
|
+
const isStringList = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string')
|
|
163
|
+
|
|
164
|
+
switch (action.type) {
|
|
165
|
+
case 'payment': {
|
|
166
|
+
const p = scope.payments
|
|
167
|
+
checked.push('payments.enabled')
|
|
168
|
+
const fault = sectionFault('payments', p, false)
|
|
169
|
+
if (fault) return deny(fault)
|
|
170
|
+
if (!granted(p)) return deny('scope does not grant payments')
|
|
171
|
+
|
|
172
|
+
if (!Number.isFinite(action.amount) || !(action.amount > 0)) {
|
|
173
|
+
return deny('payment amount must be a positive number')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (p.maxPerTransaction !== undefined) {
|
|
177
|
+
checked.push('payments.maxPerTransaction')
|
|
178
|
+
if (!isLimit(p.maxPerTransaction)) {
|
|
179
|
+
return deny('payments.maxPerTransaction must be a positive number, so it cannot be evaluated')
|
|
180
|
+
}
|
|
181
|
+
if (action.amount > p.maxPerTransaction) {
|
|
182
|
+
return deny(
|
|
183
|
+
`amount ${action.amount} exceeds maxPerTransaction ${p.maxPerTransaction}`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (p.maxPerDay !== undefined) {
|
|
189
|
+
checked.push('payments.maxPerDay')
|
|
190
|
+
if (!isLimit(p.maxPerDay)) {
|
|
191
|
+
return deny('payments.maxPerDay must be a positive number, so it cannot be evaluated')
|
|
192
|
+
}
|
|
193
|
+
if (!Number.isFinite(action.spentToday) || action.spentToday < 0) {
|
|
194
|
+
return deny(
|
|
195
|
+
'payments.maxPerDay is set, so spentToday is required to evaluate it',
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
if (action.spentToday + action.amount > p.maxPerDay) {
|
|
199
|
+
return deny(
|
|
200
|
+
`amount ${action.amount} would take today's total to ` +
|
|
201
|
+
`${action.spentToday + action.amount}, past maxPerDay ${p.maxPerDay}`,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (p.allowedRecipients !== undefined) {
|
|
207
|
+
checked.push('payments.allowedRecipients')
|
|
208
|
+
if (!isStringList(p.allowedRecipients)) {
|
|
209
|
+
return deny('payments.allowedRecipients must be an array of strings, so it cannot be evaluated')
|
|
210
|
+
}
|
|
211
|
+
if (typeof action.recipient !== 'string') {
|
|
212
|
+
return deny('payments.allowedRecipients is set, so a recipient is required')
|
|
213
|
+
}
|
|
214
|
+
// EVM addresses are case-insensitive; kids are lowercase hex.
|
|
215
|
+
const want = action.recipient.toLowerCase()
|
|
216
|
+
const ok = p.allowedRecipients.some((r) => r.toLowerCase() === want)
|
|
217
|
+
if (!ok) return deny(`recipient ${action.recipient} is not in allowedRecipients`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return allow()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case 'attestation': {
|
|
224
|
+
const a = scope.attestations
|
|
225
|
+
checked.push('attestations.enabled')
|
|
226
|
+
const fault = sectionFault('attestations', a, false)
|
|
227
|
+
if (fault) return deny(fault)
|
|
228
|
+
if (!granted(a)) return deny('scope does not grant attestations')
|
|
229
|
+
|
|
230
|
+
if (a.purposes !== undefined) {
|
|
231
|
+
checked.push('attestations.purposes')
|
|
232
|
+
if (!isStringList(a.purposes)) {
|
|
233
|
+
return deny('attestations.purposes must be an array of strings, so it cannot be evaluated')
|
|
234
|
+
}
|
|
235
|
+
if (typeof action.purpose !== 'string') {
|
|
236
|
+
return deny('attestations.purposes is set, so a purpose is required')
|
|
237
|
+
}
|
|
238
|
+
if (!a.purposes.includes(action.purpose)) {
|
|
239
|
+
return deny(`purpose '${action.purpose}' is not in attestations.purposes`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return allow()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
case 'auditLog': {
|
|
246
|
+
checked.push('auditLog')
|
|
247
|
+
const fault = sectionFault('auditLog', scope.auditLog, true)
|
|
248
|
+
if (fault) return deny(fault)
|
|
249
|
+
return granted(scope.auditLog) ? allow() : deny('scope does not grant auditLog')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case 'credentials': {
|
|
253
|
+
checked.push('credentials')
|
|
254
|
+
const fault = sectionFault('credentials', scope.credentials, true)
|
|
255
|
+
if (fault) return deny(fault)
|
|
256
|
+
return granted(scope.credentials)
|
|
257
|
+
? allow()
|
|
258
|
+
: deny('scope does not grant credentials')
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
default:
|
|
262
|
+
return deny(`unknown action type '${action.type}'`)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
84
266
|
/**
|
|
85
267
|
* Compute a hex SHA-256 of the JCS-canonical scope.
|
|
86
268
|
* This hash is stored on-chain so the relay can verify scope integrity.
|