kxco-pq-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +34 -0
- package/README.md +155 -0
- package/package.json +62 -0
- package/src/agent-client.js +147 -0
- package/src/agent-identity.js +299 -0
- package/src/errors.js +7 -0
- package/src/index.d.ts +146 -0
- package/src/index.js +4 -0
- package/src/jcs.js +34 -0
- package/src/scope.js +94 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction, and distribution.
|
|
10
|
+
"Licensor" shall mean KXCO by Knightsbridge.
|
|
11
|
+
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity.
|
|
12
|
+
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
|
13
|
+
"Source" form shall mean the preferred form for making modifications.
|
|
14
|
+
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form.
|
|
15
|
+
"Work" shall mean the work of authorship made available under the License.
|
|
16
|
+
"Derivative Works" shall mean any work that is based on the Work.
|
|
17
|
+
"Contribution" shall mean any work of authorship submitted to the Licensor for inclusion in the Work.
|
|
18
|
+
"Contributor" shall mean Licensor and any Legal Entity on behalf of whom a Contribution has been received by the Licensor and incorporated within the Work.
|
|
19
|
+
|
|
20
|
+
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
|
21
|
+
|
|
22
|
+
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work.
|
|
23
|
+
|
|
24
|
+
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work; and (d) If the Work includes a "NOTICE" text file, You must include a readable copy of the attribution notices contained within such NOTICE file.
|
|
25
|
+
|
|
26
|
+
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions.
|
|
27
|
+
|
|
28
|
+
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor.
|
|
29
|
+
|
|
30
|
+
7. Disclaimer of Warranty. THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
|
31
|
+
|
|
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
|
+
|
|
34
|
+
Copyright 2026 KXCO by Knightsbridge
|
package/README.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# kxco-pq-agent
|
|
2
|
+
|
|
3
|
+
Post-quantum AI agent and robot identity for the KXCO platform.
|
|
4
|
+
|
|
5
|
+
Institutions that hold a `KxcoIdentity` (from `kxco-pq-sdk`) can sponsor machine identities — LLMs, robots, IoT devices, and automated processes — that cannot pass KYC themselves. The sponsoring institution signs the agent's ML-DSA-65 public key and a structured capability scope, anchoring the agent's authority to their own verified identity.
|
|
6
|
+
|
|
7
|
+
**Not open source.** This package is part of the KXCO Chain platform. Contact hello@kxco.ai for licensing.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## How it works
|
|
12
|
+
|
|
13
|
+
1. A KYC'd institution generates an ML-DSA-65 keypair for the agent
|
|
14
|
+
2. The institution signs a credential binding the agent's public key to a capability scope
|
|
15
|
+
3. The agent presents this credential with every relay request, signed with its own private key
|
|
16
|
+
4. The KXCO relay validates both the credential (institution's signature) and the intent (agent's signature) before submitting to chain
|
|
17
|
+
|
|
18
|
+
The credential scope is locked at issuance. To change an agent's permissions, revoke and re-issue.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install kxco-pq-agent
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### Issue an agent credential
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
import { KxcoAgentIdentity } from 'kxco-pq-agent'
|
|
36
|
+
|
|
37
|
+
// `sponsor` is a KxcoIdentity from kxco-pq-sdk
|
|
38
|
+
const agent = await KxcoAgentIdentity.create({
|
|
39
|
+
sponsor,
|
|
40
|
+
label: 'Trading Bot v2',
|
|
41
|
+
agentType: 'llm',
|
|
42
|
+
model: 'claude-opus-4-7', // optional — model/firmware/version string
|
|
43
|
+
scope: {
|
|
44
|
+
payments: {
|
|
45
|
+
maxPerTransaction: 5000, // ARMR, in smallest denomination
|
|
46
|
+
maxPerDay: 50000,
|
|
47
|
+
allowedRecipients: [
|
|
48
|
+
'0xAbC123...', // EVM address
|
|
49
|
+
'aa29f37ab7f4b2cf', // KXCO kid
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
attestations: {
|
|
53
|
+
purposes: ['trade-confirmation', 'settlement-receipt'],
|
|
54
|
+
},
|
|
55
|
+
auditLog: true,
|
|
56
|
+
credentials: false,
|
|
57
|
+
},
|
|
58
|
+
expiresIn: '90d', // '30d', '1y', or seconds as a number
|
|
59
|
+
chain, // optional KxcoChain — records the credential on-chain
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Sign a message
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
const signature = await agent.sign(new TextEncoder().encode('hello'))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Export and restore
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// Save to secure storage
|
|
73
|
+
const exported = agent.export()
|
|
74
|
+
|
|
75
|
+
// Restore in another process
|
|
76
|
+
const restored = await KxcoAgentIdentity.import(exported)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Send relay operations
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
import { KxcoChain } from 'kxco-pq-chain'
|
|
83
|
+
|
|
84
|
+
const chain = new KxcoChain({ relay: 'https://relay.kxco.ai', identity: sponsor })
|
|
85
|
+
const client = agent.toChainClient('https://relay.kxco.ai')
|
|
86
|
+
|
|
87
|
+
const result = await client.anchorAttestation({
|
|
88
|
+
payloadHash: '9f86d081884c7d65...',
|
|
89
|
+
purpose: 'trade-confirmation',
|
|
90
|
+
})
|
|
91
|
+
// → { txHash: '0x...', blockNumber: 228345 }
|
|
92
|
+
|
|
93
|
+
await client.anchorAuditRoot({ rootHash: '...', entryCount: 100 })
|
|
94
|
+
await client.transfer({ recipientKid: 'aa29f37ab7f4b2cf', amount: 1000 })
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Verify a credential
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
const result = await KxcoAgentIdentity.verify(credential, {
|
|
101
|
+
sponsorPublicKey: sponsor.publicKey, // optional — skip to check format only
|
|
102
|
+
})
|
|
103
|
+
// → { valid: true, agentKid: '...', sponsorKid: '...', scope: {...} }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Revoke an agent
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
await KxcoAgentIdentity.revoke(agent.credential.agentKid, {
|
|
110
|
+
chain,
|
|
111
|
+
reason: 'Decommissioned',
|
|
112
|
+
})
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Scope manifest
|
|
118
|
+
|
|
119
|
+
The scope is signed by the institution and cannot be modified after issuance.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
interface AgentScope {
|
|
123
|
+
payments?: {
|
|
124
|
+
maxPerTransaction: number // in smallest denomination
|
|
125
|
+
maxPerDay: number
|
|
126
|
+
allowedRecipients: string[] // EVM addresses (0x + 40 hex) or KXCO kids (16 hex)
|
|
127
|
+
}
|
|
128
|
+
attestations?: {
|
|
129
|
+
purposes: string[] // allowed purpose strings
|
|
130
|
+
}
|
|
131
|
+
auditLog?: boolean // can anchor audit checkpoints
|
|
132
|
+
credentials?: boolean // can manage credentials (use sparingly)
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The scope hash (SHA-256 of JCS-canonical scope JSON) is stored on-chain at issuance.
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Agent types
|
|
141
|
+
|
|
142
|
+
| Type | Use |
|
|
143
|
+
|------|-----|
|
|
144
|
+
| `llm` | Large language models and AI assistants |
|
|
145
|
+
| `robot` | Physical robots and automated machinery |
|
|
146
|
+
| `iot` | IoT devices and sensors |
|
|
147
|
+
| `process` | Automated software processes and daemons |
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Authors
|
|
152
|
+
|
|
153
|
+
Shayne Heffernan and John Heffernan — KXCO by Knightsbridge
|
|
154
|
+
|
|
155
|
+
hello@kxco.ai | https://kxco.ai
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kxco-pq-agent",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AI agent and robot identity for the KXCO post-quantum stack. Any KxcoIdentity holder may sponsor an agent with a locked ML-DSA-65 keypair and capability scope — payments, attestations, audit anchoring.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"post-quantum",
|
|
7
|
+
"pqc",
|
|
8
|
+
"ml-dsa",
|
|
9
|
+
"nist",
|
|
10
|
+
"fips-204",
|
|
11
|
+
"kxco",
|
|
12
|
+
"agent-identity",
|
|
13
|
+
"robot-identity",
|
|
14
|
+
"machine-identity",
|
|
15
|
+
"ai-agent",
|
|
16
|
+
"capability-scope",
|
|
17
|
+
"armature",
|
|
18
|
+
"relay",
|
|
19
|
+
"blockchain"
|
|
20
|
+
],
|
|
21
|
+
"license": "Apache-2.0",
|
|
22
|
+
"author": "KXCO by Knightsbridge <hello@kxco.ai>",
|
|
23
|
+
"contributors": [
|
|
24
|
+
{ "name": "Shayne Heffernan" },
|
|
25
|
+
{ "name": "John Heffernan" }
|
|
26
|
+
],
|
|
27
|
+
"homepage": "https://kxco.ai",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/JackKXCO/kxco-pq-agent.git"
|
|
31
|
+
},
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/JackKXCO/kxco-pq-agent/issues"
|
|
34
|
+
},
|
|
35
|
+
"type": "module",
|
|
36
|
+
"sideEffects": false,
|
|
37
|
+
"main": "./src/index.js",
|
|
38
|
+
"types": "./src/index.d.ts",
|
|
39
|
+
"exports": {
|
|
40
|
+
".": {
|
|
41
|
+
"types": "./src/index.d.ts",
|
|
42
|
+
"import": "./src/index.js"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"src",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"engines": {
|
|
50
|
+
"node": ">=20.19"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"kxco-post-quantum": "^1.1.6"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"test": "node --test --test-timeout=30000 test/agent.test.js"
|
|
57
|
+
},
|
|
58
|
+
"funding": "https://kxco.ai",
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { KxcoPqAgentError } from './errors.js'
|
|
2
|
+
import { canonicalize } from './jcs.js'
|
|
3
|
+
|
|
4
|
+
const enc = new TextEncoder()
|
|
5
|
+
|
|
6
|
+
function randomNonce() {
|
|
7
|
+
const bytes = new Uint8Array(32)
|
|
8
|
+
globalThis.crypto.getRandomValues(bytes)
|
|
9
|
+
return Buffer.from(bytes).toString('hex')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function sha256Hex(str) {
|
|
13
|
+
const buf = await globalThis.crypto.subtle.digest('SHA-256', enc.encode(str))
|
|
14
|
+
return Buffer.from(buf).toString('hex')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function buildSigningMessage(operation, agentKid, sponsorKid, nonce, timestamp, credentialHash, payload) {
|
|
18
|
+
return enc.encode([
|
|
19
|
+
'kxco-relay-agent-v1',
|
|
20
|
+
`operation: ${operation}`,
|
|
21
|
+
`agentKid: ${agentKid}`,
|
|
22
|
+
`sponsorKid: ${sponsorKid}`,
|
|
23
|
+
`nonce: ${nonce}`,
|
|
24
|
+
`timestamp: ${timestamp}`,
|
|
25
|
+
`credentialHash: ${credentialHash}`,
|
|
26
|
+
`payload: ${canonicalize(payload)}`,
|
|
27
|
+
].join('\n'))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* AgentChainClient — relay client for KxcoAgentIdentity.
|
|
32
|
+
*
|
|
33
|
+
* Sends ML-DSA-65 signed intents to the KXCO relay's /agent-intents endpoint.
|
|
34
|
+
* Each request includes the agent's signed credential so the relay can verify
|
|
35
|
+
* the sponsor's authorisation and enforce scope.
|
|
36
|
+
*
|
|
37
|
+
* Returned by KxcoAgentIdentity.toChainClient().
|
|
38
|
+
*/
|
|
39
|
+
export class AgentChainClient {
|
|
40
|
+
#relay
|
|
41
|
+
#agent
|
|
42
|
+
#timeout
|
|
43
|
+
#credentialB64
|
|
44
|
+
#credentialHash // lazy, cached after first use
|
|
45
|
+
|
|
46
|
+
constructor({ relay, agent, timeout = 10_000 }) {
|
|
47
|
+
if (!relay) throw new KxcoPqAgentError('relay URL is required', { code: 'BAD_CONFIG' })
|
|
48
|
+
if (!agent) throw new KxcoPqAgentError('agent is required', { code: 'BAD_CONFIG' })
|
|
49
|
+
this.#relay = relay.replace(/\/$/, '')
|
|
50
|
+
this.#agent = agent
|
|
51
|
+
this.#timeout = timeout
|
|
52
|
+
this.#credentialB64 = Buffer.from(JSON.stringify(agent.credential)).toString('base64')
|
|
53
|
+
this.#credentialHash = null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async #getCredentialHash() {
|
|
57
|
+
if (!this.#credentialHash) {
|
|
58
|
+
this.#credentialHash = await sha256Hex(this.#credentialB64)
|
|
59
|
+
}
|
|
60
|
+
return this.#credentialHash
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── Operations ───────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/** Anchor an attestation envelope hash on-chain (requires scope.attestations.enabled). */
|
|
66
|
+
async anchorAttestation({ payloadHash, purpose }) {
|
|
67
|
+
return this.#send('anchorAttestation', { payloadHash, purpose })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Anchor an audit log checkpoint on-chain (requires scope.auditLog.enabled). */
|
|
71
|
+
async anchorAuditRoot({ rootHash, entryCount }) {
|
|
72
|
+
return this.#send('anchorAuditRoot', { rootHash, entryCount })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Submit a payment intent (requires scope.payments.enabled).
|
|
77
|
+
* The relay enforces allowedRecipients, maxPerTransaction, and maxPerDay.
|
|
78
|
+
* @param {object} opts
|
|
79
|
+
* @param {string} opts.to — EVM address or KXCO kid of the recipient
|
|
80
|
+
* @param {number} opts.amount — amount in ARMR
|
|
81
|
+
*/
|
|
82
|
+
async transfer({ to, amount }) {
|
|
83
|
+
return this.#send('transfer', { to, amount })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Internal ─────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
async #send(operation, payload) {
|
|
89
|
+
const nonce = randomNonce()
|
|
90
|
+
const timestamp = Math.floor(Date.now() / 1000)
|
|
91
|
+
const credentialHash = await this.#getCredentialHash()
|
|
92
|
+
const agentKid = this.#agent.kid
|
|
93
|
+
const sponsorKid = this.#agent.sponsorKid
|
|
94
|
+
|
|
95
|
+
const msg = buildSigningMessage(operation, agentKid, sponsorKid, nonce, timestamp, credentialHash, payload)
|
|
96
|
+
const sigBytes = await this.#agent.sign(msg)
|
|
97
|
+
const signature = Buffer.from(sigBytes).toString('hex')
|
|
98
|
+
|
|
99
|
+
const intent = {
|
|
100
|
+
operation,
|
|
101
|
+
agentKid,
|
|
102
|
+
sponsorKid,
|
|
103
|
+
agentCredential: this.#credentialB64,
|
|
104
|
+
credentialHash,
|
|
105
|
+
nonce,
|
|
106
|
+
timestamp,
|
|
107
|
+
payload,
|
|
108
|
+
signature,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const ac = new AbortController()
|
|
112
|
+
const tid = setTimeout(() => ac.abort(), this.#timeout)
|
|
113
|
+
|
|
114
|
+
let response
|
|
115
|
+
try {
|
|
116
|
+
response = await fetch(`${this.#relay}/agent-intents`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'content-type': 'application/json' },
|
|
119
|
+
body: JSON.stringify(intent),
|
|
120
|
+
signal: ac.signal,
|
|
121
|
+
})
|
|
122
|
+
} catch (err) {
|
|
123
|
+
clearTimeout(tid)
|
|
124
|
+
if (err.name === 'AbortError') {
|
|
125
|
+
throw new KxcoPqAgentError(`relay request timed out after ${this.#timeout}ms`, { code: 'TIMEOUT' })
|
|
126
|
+
}
|
|
127
|
+
throw new KxcoPqAgentError(`relay request failed: ${err.message}`, { code: 'NETWORK_ERROR' })
|
|
128
|
+
}
|
|
129
|
+
clearTimeout(tid)
|
|
130
|
+
|
|
131
|
+
let body
|
|
132
|
+
try {
|
|
133
|
+
body = await response.json()
|
|
134
|
+
} catch {
|
|
135
|
+
throw new KxcoPqAgentError('relay returned non-JSON response', { code: 'PARSE_ERROR', status: response.status })
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!response.ok || body.ok === false) {
|
|
139
|
+
throw new KxcoPqAgentError(
|
|
140
|
+
body.error ?? `relay error ${response.status}`,
|
|
141
|
+
{ code: body.code ?? 'RELAY_ERROR' }
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { txHash: body.txHash, blockNumber: body.blockNumber }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { mlDsa, fingerprint } from 'kxco-post-quantum'
|
|
2
|
+
import { validateScope, hashScope } from './scope.js'
|
|
3
|
+
import { canonicalize } from './jcs.js'
|
|
4
|
+
import { KxcoPqAgentError } from './errors.js'
|
|
5
|
+
import { AgentChainClient } from './agent-client.js'
|
|
6
|
+
|
|
7
|
+
const CREDENTIAL_VERSION = '1'
|
|
8
|
+
const IDENTITY_VERSION = '1'
|
|
9
|
+
|
|
10
|
+
const enc = new TextEncoder()
|
|
11
|
+
|
|
12
|
+
function b64url(bytes) {
|
|
13
|
+
return Buffer.from(bytes).toString('base64url')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fromB64url(s) {
|
|
17
|
+
return new Uint8Array(Buffer.from(s, 'base64url'))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseDuration(val) {
|
|
21
|
+
if (typeof val === 'number') return val * 1000
|
|
22
|
+
const m = String(val).match(/^(\d+)(d|h|m|s|y)$/)
|
|
23
|
+
if (!m) throw new KxcoPqAgentError(`invalid expiresIn '${val}' — use '30d', '1y', or seconds as a number`)
|
|
24
|
+
const n = parseInt(m[1], 10)
|
|
25
|
+
const ms = { d: 86400000, h: 3600000, m: 60000, s: 1000, y: 31536000000 }
|
|
26
|
+
return n * ms[m[2]]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function credentialSigningMsg({ agentKid, agentPublicKey, sponsorKid, agentType, label, model, scope, issuedAt, expiresAt }) {
|
|
30
|
+
return enc.encode([
|
|
31
|
+
'kxco-agent-credential-v1',
|
|
32
|
+
agentKid,
|
|
33
|
+
agentPublicKey,
|
|
34
|
+
sponsorKid,
|
|
35
|
+
agentType,
|
|
36
|
+
label,
|
|
37
|
+
model ?? '',
|
|
38
|
+
canonicalize(scope),
|
|
39
|
+
issuedAt,
|
|
40
|
+
expiresAt,
|
|
41
|
+
].join('\n'))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const VALID_AGENT_TYPES = new Set(['llm', 'robot', 'iot', 'process'])
|
|
45
|
+
|
|
46
|
+
export class KxcoAgentIdentity {
|
|
47
|
+
#kid
|
|
48
|
+
#keypair
|
|
49
|
+
#sponsorKid
|
|
50
|
+
#agentType
|
|
51
|
+
#label
|
|
52
|
+
#model
|
|
53
|
+
#scope
|
|
54
|
+
#issuedAt
|
|
55
|
+
#expiresAt
|
|
56
|
+
#credential
|
|
57
|
+
|
|
58
|
+
constructor(opts) {
|
|
59
|
+
this.#kid = opts.kid
|
|
60
|
+
this.#keypair = opts.keypair
|
|
61
|
+
this.#sponsorKid = opts.sponsorKid
|
|
62
|
+
this.#agentType = opts.agentType
|
|
63
|
+
this.#label = opts.label
|
|
64
|
+
this.#model = opts.model ?? null
|
|
65
|
+
this.#scope = opts.scope
|
|
66
|
+
this.#issuedAt = opts.issuedAt
|
|
67
|
+
this.#expiresAt = opts.expiresAt
|
|
68
|
+
this.#credential = opts.credential
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get kid() { return this.#kid }
|
|
72
|
+
get sponsorKid() { return this.#sponsorKid }
|
|
73
|
+
get agentType() { return this.#agentType }
|
|
74
|
+
get label() { return this.#label }
|
|
75
|
+
get model() { return this.#model }
|
|
76
|
+
get scope() { return JSON.parse(JSON.stringify(this.#scope)) }
|
|
77
|
+
get issuedAt() { return this.#issuedAt }
|
|
78
|
+
get expiresAt() { return this.#expiresAt }
|
|
79
|
+
get credential() { return JSON.parse(JSON.stringify(this.#credential)) }
|
|
80
|
+
|
|
81
|
+
// ── Factory ───────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create a new agent identity. Any KxcoIdentity holder may sponsor an agent.
|
|
85
|
+
*
|
|
86
|
+
* @param {object} opts
|
|
87
|
+
* @param {{ kid: string, sign(msg: Uint8Array): Promise<Uint8Array> }} opts.sponsor
|
|
88
|
+
* @param {string} opts.label — human-readable name for this agent
|
|
89
|
+
* @param {'llm'|'robot'|'iot'|'process'} opts.agentType
|
|
90
|
+
* @param {string} [opts.model] — model/hardware identifier (optional)
|
|
91
|
+
* @param {object} opts.scope — locked capability manifest (see scope.js)
|
|
92
|
+
* @param {string|number} opts.expiresIn — '30d', '1y', or seconds as number (mandatory)
|
|
93
|
+
* @param {object} [opts.chain] — KxcoChain instance for on-chain registration
|
|
94
|
+
*/
|
|
95
|
+
static async create({ sponsor, label, agentType, model, scope, expiresIn, chain } = {}) {
|
|
96
|
+
if (!sponsor?.kid || typeof sponsor.sign !== 'function') {
|
|
97
|
+
throw new KxcoPqAgentError('create: sponsor must have .kid and .sign(message)')
|
|
98
|
+
}
|
|
99
|
+
if (!label) throw new KxcoPqAgentError('create: label is required')
|
|
100
|
+
if (!agentType) throw new KxcoPqAgentError('create: agentType is required')
|
|
101
|
+
if (!VALID_AGENT_TYPES.has(agentType)) {
|
|
102
|
+
throw new KxcoPqAgentError(`create: agentType must be one of: ${[...VALID_AGENT_TYPES].join(', ')}`)
|
|
103
|
+
}
|
|
104
|
+
if (!scope) throw new KxcoPqAgentError('create: scope is required')
|
|
105
|
+
if (expiresIn == null) throw new KxcoPqAgentError('create: expiresIn is required — agents must have an expiry')
|
|
106
|
+
|
|
107
|
+
validateScope(scope)
|
|
108
|
+
|
|
109
|
+
const keypair = mlDsa.ml_dsa65.keygen()
|
|
110
|
+
const agentKid = fingerprint(keypair.publicKey)
|
|
111
|
+
const agentPubB64 = b64url(keypair.publicKey)
|
|
112
|
+
const issuedAt = new Date().toISOString()
|
|
113
|
+
const expiresAt = new Date(Date.now() + parseDuration(expiresIn)).toISOString()
|
|
114
|
+
|
|
115
|
+
const sigMsg = credentialSigningMsg({
|
|
116
|
+
agentKid,
|
|
117
|
+
agentPublicKey: agentPubB64,
|
|
118
|
+
sponsorKid: sponsor.kid,
|
|
119
|
+
agentType,
|
|
120
|
+
label,
|
|
121
|
+
model,
|
|
122
|
+
scope,
|
|
123
|
+
issuedAt,
|
|
124
|
+
expiresAt,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
const sigBytes = await sponsor.sign(sigMsg)
|
|
128
|
+
const credential = {
|
|
129
|
+
'kxco-agent': CREDENTIAL_VERSION,
|
|
130
|
+
agentKid,
|
|
131
|
+
agentPublicKey: agentPubB64,
|
|
132
|
+
sponsorKid: sponsor.kid,
|
|
133
|
+
agentType,
|
|
134
|
+
label,
|
|
135
|
+
...(model && { model }),
|
|
136
|
+
scope,
|
|
137
|
+
issuedAt,
|
|
138
|
+
expiresAt,
|
|
139
|
+
sponsorSignature: b64url(sigBytes),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (chain) {
|
|
143
|
+
const scopeHash = await hashScope(scope)
|
|
144
|
+
const expiresAtSec = Math.floor(new Date(expiresAt).getTime() / 1000)
|
|
145
|
+
await chain.issueAgentCredential({
|
|
146
|
+
agentKid,
|
|
147
|
+
agentPublicKeyHex: Buffer.from(keypair.publicKey).toString('hex'),
|
|
148
|
+
agentType,
|
|
149
|
+
scopeHash,
|
|
150
|
+
expiresAt: expiresAtSec,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return new KxcoAgentIdentity({
|
|
155
|
+
kid: agentKid,
|
|
156
|
+
keypair,
|
|
157
|
+
sponsorKid: sponsor.kid,
|
|
158
|
+
agentType,
|
|
159
|
+
label,
|
|
160
|
+
model: model ?? null,
|
|
161
|
+
scope,
|
|
162
|
+
issuedAt,
|
|
163
|
+
expiresAt,
|
|
164
|
+
credential,
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Signing ───────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
async sign(message) {
|
|
171
|
+
if (!this.#keypair?.secretKey) {
|
|
172
|
+
throw new KxcoPqAgentError('no signing key — reconstruct with KxcoAgentIdentity.import()')
|
|
173
|
+
}
|
|
174
|
+
return mlDsa.ml_dsa65.sign(
|
|
175
|
+
new Uint8Array(this.#keypair.secretKey),
|
|
176
|
+
new Uint8Array(message),
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async getPublicKey() {
|
|
181
|
+
return this.#keypair.publicKey
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Export / import ───────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
export() {
|
|
187
|
+
return {
|
|
188
|
+
'kxco-agent-identity': IDENTITY_VERSION,
|
|
189
|
+
kid: this.#kid,
|
|
190
|
+
sponsorKid: this.#sponsorKid,
|
|
191
|
+
agentType: this.#agentType,
|
|
192
|
+
label: this.#label,
|
|
193
|
+
...(this.#model && { model: this.#model }),
|
|
194
|
+
scope: this.#scope,
|
|
195
|
+
issuedAt: this.#issuedAt,
|
|
196
|
+
expiresAt: this.#expiresAt,
|
|
197
|
+
secretKey: b64url(this.#keypair.secretKey),
|
|
198
|
+
publicKey: b64url(this.#keypair.publicKey),
|
|
199
|
+
credential: this.#credential,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
static async import(exported) {
|
|
204
|
+
if (!exported || exported['kxco-agent-identity'] !== IDENTITY_VERSION) {
|
|
205
|
+
throw new KxcoPqAgentError('import: invalid or unsupported agent identity format')
|
|
206
|
+
}
|
|
207
|
+
return new KxcoAgentIdentity({
|
|
208
|
+
kid: exported.kid,
|
|
209
|
+
keypair: { secretKey: fromB64url(exported.secretKey), publicKey: fromB64url(exported.publicKey) },
|
|
210
|
+
sponsorKid: exported.sponsorKid,
|
|
211
|
+
agentType: exported.agentType,
|
|
212
|
+
label: exported.label,
|
|
213
|
+
model: exported.model ?? null,
|
|
214
|
+
scope: exported.scope,
|
|
215
|
+
issuedAt: exported.issuedAt,
|
|
216
|
+
expiresAt: exported.expiresAt,
|
|
217
|
+
credential: exported.credential,
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ── Chain client ──────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Returns an AgentChainClient that sends agent-signed intents to the relay.
|
|
225
|
+
* Each request automatically includes this agent's credential and kid.
|
|
226
|
+
* @param {string} relay — relay base URL, e.g. 'https://relay.kxco.ai'
|
|
227
|
+
* @param {{ timeout?: number }} [opts]
|
|
228
|
+
*/
|
|
229
|
+
toChainClient(relay, { timeout } = {}) {
|
|
230
|
+
return new AgentChainClient({ relay, agent: this, ...(timeout && { timeout }) })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Static: verify a credential ──────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Verify an agent credential envelope.
|
|
237
|
+
* Pass sponsorPublicKey (Uint8Array) to perform full ML-DSA-65 signature verification.
|
|
238
|
+
* Without it, only expiry and format are checked.
|
|
239
|
+
*
|
|
240
|
+
* @param {object} credential
|
|
241
|
+
* @param {{ sponsorPublicKey?: Uint8Array }} [opts]
|
|
242
|
+
*/
|
|
243
|
+
static async verify(credential, { sponsorPublicKey } = {}) {
|
|
244
|
+
if (!credential || credential['kxco-agent'] !== CREDENTIAL_VERSION) {
|
|
245
|
+
return { valid: false, error: 'invalid or unsupported credential format' }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const {
|
|
249
|
+
agentKid, agentPublicKey, sponsorKid, agentType,
|
|
250
|
+
label, model, scope, issuedAt, expiresAt, sponsorSignature,
|
|
251
|
+
} = credential
|
|
252
|
+
|
|
253
|
+
if (!agentKid || !agentPublicKey || !sponsorKid || !agentType || !issuedAt || !expiresAt || !sponsorSignature) {
|
|
254
|
+
return { valid: false, error: 'malformed credential — missing required fields' }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (new Date(expiresAt) < new Date()) {
|
|
258
|
+
return { valid: false, error: 'agent credential has expired' }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (sponsorPublicKey) {
|
|
262
|
+
const msg = credentialSigningMsg({ agentKid, agentPublicKey, sponsorKid, agentType, label, model, scope, issuedAt, expiresAt })
|
|
263
|
+
let ok
|
|
264
|
+
try {
|
|
265
|
+
ok = mlDsa.ml_dsa65.verify(new Uint8Array(sponsorPublicKey), msg, fromB64url(sponsorSignature))
|
|
266
|
+
} catch {
|
|
267
|
+
ok = false
|
|
268
|
+
}
|
|
269
|
+
if (!ok) return { valid: false, error: 'sponsor signature invalid' }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
valid: true,
|
|
274
|
+
agentKid,
|
|
275
|
+
sponsorKid,
|
|
276
|
+
agentType,
|
|
277
|
+
label,
|
|
278
|
+
...(model && { model }),
|
|
279
|
+
scope,
|
|
280
|
+
issuedAt,
|
|
281
|
+
expiresAt,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── Static: revoke on-chain ───────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Revoke an agent credential on-chain.
|
|
289
|
+
* The chain parameter must be a KxcoChain instance belonging to the sponsor.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} agentKid
|
|
292
|
+
* @param {{ chain: object, reason?: string }} opts
|
|
293
|
+
*/
|
|
294
|
+
static async revoke(agentKid, { chain, reason = '' } = {}) {
|
|
295
|
+
if (!agentKid) throw new KxcoPqAgentError('revoke: agentKid is required')
|
|
296
|
+
if (!chain) throw new KxcoPqAgentError('revoke: chain is required for on-chain revocation')
|
|
297
|
+
await chain.revokeAgentCredential({ agentKid, reason })
|
|
298
|
+
}
|
|
299
|
+
}
|
package/src/errors.js
ADDED
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// kxco-pq-agent — AI agent and robot identity for the KXCO PQ stack
|
|
2
|
+
|
|
3
|
+
// ── Error ────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export class KxcoPqAgentError extends Error {
|
|
6
|
+
name: 'KxcoPqAgentError'
|
|
7
|
+
code: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// ── Scope ────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export interface PaymentScope {
|
|
13
|
+
enabled: boolean
|
|
14
|
+
maxPerTransaction?: number // ARMR; must be ≤ maxPerDay
|
|
15
|
+
maxPerDay?: number // ARMR
|
|
16
|
+
/**
|
|
17
|
+
* Allowed recipients — EVM addresses (0x + 40 hex) or KXCO kids (16 lowercase hex).
|
|
18
|
+
* Empty array = locked to that empty whitelist (no payments allowed).
|
|
19
|
+
* Absent / undefined = any recipient.
|
|
20
|
+
*/
|
|
21
|
+
allowedRecipients?: string[]
|
|
22
|
+
currency?: string // e.g. 'ARMR'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface AttestationScope {
|
|
26
|
+
enabled: boolean
|
|
27
|
+
/** Allowed purposes. Empty array = any purpose. */
|
|
28
|
+
purposes?: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AgentScope {
|
|
32
|
+
payments?: PaymentScope
|
|
33
|
+
attestations?: AttestationScope
|
|
34
|
+
auditLog?: { enabled: boolean }
|
|
35
|
+
credentials?: { enabled: boolean }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateScope(scope: AgentScope): AgentScope
|
|
39
|
+
export function hashScope(scope: AgentScope): Promise<string>
|
|
40
|
+
|
|
41
|
+
// ── Credential envelope ───────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
export interface AgentCredential {
|
|
44
|
+
'kxco-agent': string
|
|
45
|
+
agentKid: string
|
|
46
|
+
agentPublicKey: string // base64url ML-DSA-65 public key
|
|
47
|
+
sponsorKid: string
|
|
48
|
+
agentType: 'llm' | 'robot' | 'iot' | 'process'
|
|
49
|
+
label: string
|
|
50
|
+
model?: string
|
|
51
|
+
scope: AgentScope
|
|
52
|
+
issuedAt: string // ISO 8601
|
|
53
|
+
expiresAt: string // ISO 8601
|
|
54
|
+
sponsorSignature: string // base64url ML-DSA-65 sig by sponsor
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface VerifyResult {
|
|
58
|
+
valid: boolean
|
|
59
|
+
error?: string
|
|
60
|
+
agentKid?: string
|
|
61
|
+
sponsorKid?: string
|
|
62
|
+
agentType?: string
|
|
63
|
+
label?: string
|
|
64
|
+
model?: string
|
|
65
|
+
scope?: AgentScope
|
|
66
|
+
issuedAt?: string
|
|
67
|
+
expiresAt?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── AgentChainClient ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export interface AgentRelayResult {
|
|
73
|
+
txHash: string
|
|
74
|
+
blockNumber: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class AgentChainClient {
|
|
78
|
+
constructor(opts: { relay: string; agent: KxcoAgentIdentity; timeout?: number })
|
|
79
|
+
anchorAttestation(opts: { payloadHash: string; purpose: string }): Promise<AgentRelayResult>
|
|
80
|
+
anchorAuditRoot(opts: { rootHash: string; entryCount: number }): Promise<AgentRelayResult>
|
|
81
|
+
transfer(opts: { to: string; amount: number }): Promise<AgentRelayResult>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── KxcoAgentIdentity ─────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
export interface Sponsor {
|
|
87
|
+
kid: string
|
|
88
|
+
sign(message: Uint8Array): Promise<Uint8Array>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CreateAgentOptions {
|
|
92
|
+
sponsor: Sponsor
|
|
93
|
+
label: string
|
|
94
|
+
agentType: 'llm' | 'robot' | 'iot' | 'process'
|
|
95
|
+
model?: string
|
|
96
|
+
scope: AgentScope
|
|
97
|
+
/** Mandatory. '7d', '30d', '1y', or seconds as number. */
|
|
98
|
+
expiresIn: string | number
|
|
99
|
+
/** KxcoChain instance (from kxco-pq-chain) for on-chain registration. */
|
|
100
|
+
chain?: { issueAgentCredential(opts: object): Promise<AgentRelayResult> }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ExportedAgentIdentity {
|
|
104
|
+
'kxco-agent-identity': string
|
|
105
|
+
kid: string
|
|
106
|
+
sponsorKid: string
|
|
107
|
+
agentType: string
|
|
108
|
+
label: string
|
|
109
|
+
model?: string
|
|
110
|
+
scope: AgentScope
|
|
111
|
+
issuedAt: string
|
|
112
|
+
expiresAt: string
|
|
113
|
+
secretKey: string
|
|
114
|
+
publicKey: string
|
|
115
|
+
credential: AgentCredential
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class KxcoAgentIdentity {
|
|
119
|
+
readonly kid: string
|
|
120
|
+
readonly sponsorKid: string
|
|
121
|
+
readonly agentType: string
|
|
122
|
+
readonly label: string
|
|
123
|
+
readonly model: string | null
|
|
124
|
+
readonly scope: AgentScope
|
|
125
|
+
readonly issuedAt: string
|
|
126
|
+
readonly expiresAt: string
|
|
127
|
+
readonly credential: AgentCredential
|
|
128
|
+
|
|
129
|
+
static create(opts: CreateAgentOptions): Promise<KxcoAgentIdentity>
|
|
130
|
+
static import(exported: ExportedAgentIdentity): Promise<KxcoAgentIdentity>
|
|
131
|
+
|
|
132
|
+
static verify(
|
|
133
|
+
credential: AgentCredential,
|
|
134
|
+
opts?: { sponsorPublicKey?: Uint8Array }
|
|
135
|
+
): Promise<VerifyResult>
|
|
136
|
+
|
|
137
|
+
static revoke(
|
|
138
|
+
agentKid: string,
|
|
139
|
+
opts: { chain: { revokeAgentCredential(opts: object): Promise<AgentRelayResult> }; reason?: string }
|
|
140
|
+
): Promise<void>
|
|
141
|
+
|
|
142
|
+
sign(message: Uint8Array): Promise<Uint8Array>
|
|
143
|
+
getPublicKey(): Promise<Uint8Array>
|
|
144
|
+
export(): ExportedAgentIdentity
|
|
145
|
+
toChainClient(relay: string, opts?: { timeout?: number }): AgentChainClient
|
|
146
|
+
}
|
package/src/index.js
ADDED
package/src/jcs.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// RFC 8785 JSON Canonicalization Scheme (JCS), subset.
|
|
2
|
+
// Copied from kxco-pq-cli/src/jcs.js — do not diverge.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Canonicalize a JSON-serializable value per (a subset of) RFC 8785.
|
|
6
|
+
* @param {unknown} value
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function canonicalize(value) {
|
|
10
|
+
return JSON.stringify(walk(value))
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function walk(v) {
|
|
14
|
+
if (v === null) return null
|
|
15
|
+
if (typeof v === 'boolean') return v
|
|
16
|
+
if (typeof v === 'string') return v
|
|
17
|
+
if (typeof v === 'number') {
|
|
18
|
+
if (!Number.isFinite(v)) throw new TypeError('JCS: non-finite numbers are not representable in JSON')
|
|
19
|
+
if (!Number.isInteger(v)) throw new TypeError('JCS subset: floats are not supported')
|
|
20
|
+
return v
|
|
21
|
+
}
|
|
22
|
+
if (Array.isArray(v)) return v.map(walk)
|
|
23
|
+
if (v && typeof v === 'object') {
|
|
24
|
+
const out = {}
|
|
25
|
+
for (const k of Object.keys(v).sort()) {
|
|
26
|
+
const child = walk(v[k])
|
|
27
|
+
if (child === undefined) continue
|
|
28
|
+
out[k] = child
|
|
29
|
+
}
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
if (v === undefined) return undefined
|
|
33
|
+
throw new TypeError(`JCS: unsupported value of type ${typeof v}`)
|
|
34
|
+
}
|
package/src/scope.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { KxcoPqAgentError } from './errors.js'
|
|
2
|
+
import { canonicalize } from './jcs.js'
|
|
3
|
+
|
|
4
|
+
const EVM_RE = /^0x[0-9a-fA-F]{40}$/
|
|
5
|
+
const KID_RE = /^[0-9a-f]{16}$/
|
|
6
|
+
|
|
7
|
+
function validRecipient(s) {
|
|
8
|
+
return EVM_RE.test(s) || KID_RE.test(s)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Validate a scope object. Throws KxcoPqAgentError on any violation.
|
|
13
|
+
* Returns the scope unchanged.
|
|
14
|
+
*/
|
|
15
|
+
export function validateScope(scope) {
|
|
16
|
+
if (!scope || typeof scope !== 'object' || Array.isArray(scope)) {
|
|
17
|
+
throw new KxcoPqAgentError('scope must be a plain object')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { payments, attestations, auditLog, credentials } = scope
|
|
21
|
+
|
|
22
|
+
if (payments != null) {
|
|
23
|
+
if (typeof payments !== 'object' || Array.isArray(payments)) {
|
|
24
|
+
throw new KxcoPqAgentError('scope.payments must be an object')
|
|
25
|
+
}
|
|
26
|
+
if (payments.enabled !== false) {
|
|
27
|
+
const { maxPerTransaction: mpt, maxPerDay: mpd, allowedRecipients: ar } = payments
|
|
28
|
+
|
|
29
|
+
if (mpt !== undefined) {
|
|
30
|
+
if (typeof mpt !== 'number' || mpt <= 0) {
|
|
31
|
+
throw new KxcoPqAgentError('scope.payments.maxPerTransaction must be a positive number')
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (mpd !== undefined) {
|
|
35
|
+
if (typeof mpd !== 'number' || mpd <= 0) {
|
|
36
|
+
throw new KxcoPqAgentError('scope.payments.maxPerDay must be a positive number')
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (mpt !== undefined && mpd !== undefined && mpt > mpd) {
|
|
40
|
+
throw new KxcoPqAgentError('scope.payments.maxPerTransaction must not exceed maxPerDay')
|
|
41
|
+
}
|
|
42
|
+
if (ar !== undefined) {
|
|
43
|
+
if (!Array.isArray(ar)) {
|
|
44
|
+
throw new KxcoPqAgentError('scope.payments.allowedRecipients must be an array')
|
|
45
|
+
}
|
|
46
|
+
for (const r of ar) {
|
|
47
|
+
if (!validRecipient(r)) {
|
|
48
|
+
throw new KxcoPqAgentError(
|
|
49
|
+
`invalid recipient '${r}' — must be an EVM address (0x + 40 hex) or KXCO kid (16 lowercase hex chars)`
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (attestations != null) {
|
|
58
|
+
if (typeof attestations !== 'object' || Array.isArray(attestations)) {
|
|
59
|
+
throw new KxcoPqAgentError('scope.attestations must be an object')
|
|
60
|
+
}
|
|
61
|
+
if (attestations.enabled !== false && attestations.purposes !== undefined) {
|
|
62
|
+
if (!Array.isArray(attestations.purposes)) {
|
|
63
|
+
throw new KxcoPqAgentError('scope.attestations.purposes must be an array of strings')
|
|
64
|
+
}
|
|
65
|
+
for (const p of attestations.purposes) {
|
|
66
|
+
if (typeof p !== 'string' || !p.trim()) {
|
|
67
|
+
throw new KxcoPqAgentError('each entry in scope.attestations.purposes must be a non-empty string')
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (auditLog != null && (typeof auditLog !== 'object' || Array.isArray(auditLog))) {
|
|
74
|
+
throw new KxcoPqAgentError('scope.auditLog must be an object')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (credentials != null && (typeof credentials !== 'object' || Array.isArray(credentials))) {
|
|
78
|
+
throw new KxcoPqAgentError('scope.credentials must be an object')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return scope
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Compute a hex SHA-256 of the JCS-canonical scope.
|
|
86
|
+
* This hash is stored on-chain so the relay can verify scope integrity.
|
|
87
|
+
* @param {object} scope
|
|
88
|
+
* @returns {Promise<string>} 64-char hex string
|
|
89
|
+
*/
|
|
90
|
+
export async function hashScope(scope) {
|
|
91
|
+
const bytes = new TextEncoder().encode(canonicalize(scope))
|
|
92
|
+
const buf = await globalThis.crypto.subtle.digest('SHA-256', bytes)
|
|
93
|
+
return Buffer.from(buf).toString('hex')
|
|
94
|
+
}
|