feat(control-plane): real X.509 agent leaf issuance (closes enrollment↔mTLS gap)
- ca/issue.ts: real X.509 v3 Ed25519 leaf with SPIFFE URI SAN, signed by the intermediate; SAN built via relay-auth spiffeIdFor so it can't drift - ca/csr.ts: parse real PKCS#10 (agent's PEM) + async PoP verify; decodeCsrWire accepts PEM or base64(DER) - main.ts/env.ts: real issuer when CA_INTERMEDIATE_KEY_PATH present, else dev fallback - interop.test.ts: oracle proving relay-auth verifyAgentCert accepts the leaf (6 tests) - deps: @peculiar/x509, reflect-metadata
This commit is contained in:
218
control-plane/package-lock.json
generated
218
control-plane/package-lock.json
generated
@@ -8,9 +8,11 @@
|
|||||||
"name": "control-plane",
|
"name": "control-plane",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@peculiar/x509": "^2.0.0",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
"relay-auth": "file:../relay-auth",
|
"relay-auth": "file:../relay-auth",
|
||||||
"relay-contracts": "file:../relay-contracts",
|
"relay-contracts": "file:../relay-contracts",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -691,6 +693,162 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@peculiar/asn1-cms": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-csr": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-ecc": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-pfx": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.8.0",
|
||||||
|
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||||
|
"@peculiar/asn1-rsa": "^2.8.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-pkcs8": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-pkcs9": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.8.0",
|
||||||
|
"@peculiar/asn1-pfx": "^2.8.0",
|
||||||
|
"@peculiar/asn1-pkcs8": "^2.8.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509-attr": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-rsa": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-schema": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/utils": "^2.0.2",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-x509": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/utils": "^2.0.2",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/asn1-x509-attr": {
|
||||||
|
"version": "2.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz",
|
||||||
|
"integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-schema": "^2.8.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.8.0",
|
||||||
|
"asn1js": "^3.0.10",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/utils": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@peculiar/x509": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-r10lkuy6BNfRmyYdRAfgu6dq0HOmyIV2OLhXWE3gDEPBdX1b8miztJVyX/UxWhLwemNyDP3CLZHpDxDwSY0xaA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/asn1-cms": "^2.6.0",
|
||||||
|
"@peculiar/asn1-csr": "^2.6.0",
|
||||||
|
"@peculiar/asn1-ecc": "^2.6.0",
|
||||||
|
"@peculiar/asn1-pkcs9": "^2.6.0",
|
||||||
|
"@peculiar/asn1-rsa": "^2.6.0",
|
||||||
|
"@peculiar/asn1-schema": "^2.6.0",
|
||||||
|
"@peculiar/asn1-x509": "^2.6.0",
|
||||||
|
"pvtsutils": "^1.3.6",
|
||||||
|
"tslib": "^2.8.1",
|
||||||
|
"tsyringe": "^4.10.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@pinojs/redact": {
|
"node_modules/@pinojs/redact": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
@@ -1225,6 +1383,20 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1js": {
|
||||||
|
"version": "3.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
|
||||||
|
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"pvtsutils": "^1.3.6",
|
||||||
|
"pvutils": "^1.1.5",
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -2293,6 +2465,24 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pvtsutils": {
|
||||||
|
"version": "1.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
|
||||||
|
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pvutils": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/quick-format-unescaped": {
|
"node_modules/quick-format-unescaped": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
@@ -2329,6 +2519,12 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reflect-metadata": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/relay-auth": {
|
"node_modules/relay-auth": {
|
||||||
"resolved": "../relay-auth",
|
"resolved": "../relay-auth",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -2581,9 +2777,7 @@
|
|||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.23.0",
|
"version": "4.23.0",
|
||||||
@@ -2604,6 +2798,24 @@
|
|||||||
"fsevents": "~2.3.3"
|
"fsevents": "~2.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tsyringe": {
|
||||||
|
"version": "4.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz",
|
||||||
|
"integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^1.9.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tsyringe/node_modules/tslib": {
|
||||||
|
"version": "1.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||||
|
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||||
|
|||||||
@@ -16,11 +16,13 @@
|
|||||||
"coverage": "vitest run --coverage"
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"relay-contracts": "file:../relay-contracts",
|
"@peculiar/x509": "^2.0.0",
|
||||||
"relay-auth": "file:../relay-auth",
|
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
"pg": "^8.12.0",
|
"pg": "^8.12.0",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"relay-auth": "file:../relay-auth",
|
||||||
|
"relay-contracts": "file:../relay-contracts",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { HostRegistry } from '../registry/hosts.js'
|
|||||||
import type { PairingIssuer } from '../pairing/issue.js'
|
import type { PairingIssuer } from '../pairing/issue.js'
|
||||||
import type { PairingRedeemer } from '../pairing/redeem.js'
|
import type { PairingRedeemer } from '../pairing/redeem.js'
|
||||||
import { RedeemError } from '../pairing/redeem.js'
|
import { RedeemError } from '../pairing/redeem.js'
|
||||||
|
import { decodeCsrWire } from '../ca/csr.js'
|
||||||
import type { Deprovisioner } from '../deprovision/deprovision.js'
|
import type { Deprovisioner } from '../deprovision/deprovision.js'
|
||||||
|
|
||||||
export interface ProvisionDeps {
|
export interface ProvisionDeps {
|
||||||
@@ -30,8 +31,8 @@ const PlanSchema = z.enum(['free', 'personal', 'pro', 'team'])
|
|||||||
const StatusSchema = z.object({ status: z.enum(['active', 'suspended']) })
|
const StatusSchema = z.object({ status: z.enum(['active', 'suspended']) })
|
||||||
const EnrollSchema = z.object({
|
const EnrollSchema = z.object({
|
||||||
code: z.string().min(1),
|
code: z.string().min(1),
|
||||||
agentPubkey: z.string().min(1), // base64
|
agentPubkey: z.string().min(1), // base64 (agent sends base64url; Buffer decodes both)
|
||||||
csr: z.string().min(1), // base64
|
csr: z.string().min(1), // PKCS#10: PEM block (agent) or base64(DER) — see decodeCsrWire
|
||||||
})
|
})
|
||||||
|
|
||||||
function sendError(reply: FastifyReply, err: unknown): void {
|
function sendError(reply: FastifyReply, err: unknown): void {
|
||||||
@@ -126,7 +127,7 @@ export function buildRouter(deps: ProvisionDeps): FastifyPluginAsync {
|
|||||||
const result = await deps.redeemer.redeemPairingCode({
|
const result = await deps.redeemer.redeemPairingCode({
|
||||||
code: body.code,
|
code: body.code,
|
||||||
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
|
agentPubkey: new Uint8Array(Buffer.from(body.agentPubkey, 'base64')),
|
||||||
csr: new Uint8Array(Buffer.from(body.csr, 'base64')),
|
csr: decodeCsrWire(body.csr),
|
||||||
})
|
})
|
||||||
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64') })
|
await reply.code(201).send({ ...result, hostContentSecret: Buffer.from(result.hostContentSecret).toString('base64') })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,53 +1,147 @@
|
|||||||
/**
|
/**
|
||||||
* CSR proof-of-possession (INV14). Models a PKCS#10 CSR as `embeddedPub(32) || sig(64)` where
|
* CSR handling (INV14) — REAL PKCS#10 over the agent's Ed25519 identity.
|
||||||
* `sig = Ed25519(privateKey, CSR_CHALLENGE || embeddedPub)`. Verifying the signature against the
|
|
||||||
* embedded pubkey proves the requester holds the matching private key (the exact property a real
|
|
||||||
* PKCS#10 self-signature provides) — using builtin crypto only.
|
|
||||||
*
|
*
|
||||||
* INTEGRATION SEAM: production parses a real PKCS#10 DER (`@peculiar/x509`) and checks the
|
* The agent (`agent/src/enroll/csr.ts`) emits a standard PKCS#10 `CertificationRequest` signed by
|
||||||
* self-signature. Swapping it in does not change `signHostLeaf`'s check ORDER or reject path.
|
* its in-process Ed25519 key. This module parses that request with `@peculiar/x509`, verifies its
|
||||||
|
* self-signature (proof-of-possession — the requester holds the private key for the embedded
|
||||||
|
* pubkey), and exposes the embedded raw Ed25519 public key so `sign.ts` can gate on
|
||||||
|
* embeddedPub == agentPubkey. Parsing/verification is FAIL-CLOSED and returns a UNIFORM result: any
|
||||||
|
* malformed/forged input yields `{ ok: false }` with no distinguishing detail (see `verifyCsrPoP`).
|
||||||
|
*
|
||||||
|
* `@peculiar/x509` uses `tsyringe`, which requires the `reflect-metadata` polyfill to be loaded
|
||||||
|
* before the library — hence the side-effect import on the first line (must stay first).
|
||||||
*/
|
*/
|
||||||
import { ed25519Sign, ed25519Verify, type KeyObject } from '../util/crypto.js'
|
import 'reflect-metadata'
|
||||||
|
import * as x509 from '@peculiar/x509'
|
||||||
|
import { webcrypto } from 'node:crypto'
|
||||||
|
import { ed25519Sign, type KeyObject } from '../util/crypto.js'
|
||||||
|
|
||||||
const CSR_CHALLENGE = new TextEncoder().encode('relay-cp/csr-pop/v1|')
|
// Ed25519 signing/verification runs on Node's WebCrypto (Ed25519 supported on Node 20+).
|
||||||
|
x509.cryptoProvider.set(webcrypto)
|
||||||
|
|
||||||
/** Test/agent helper: build a CSR proving possession of `privateKey` for `embeddedPub`. */
|
// --- Ed25519 SPKI constants -------------------------------------------------------------------
|
||||||
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
|
/** Fixed 12-byte SPKI prefix for an Ed25519 SubjectPublicKeyInfo; the raw key is the trailing 32. */
|
||||||
const message = concat(CSR_CHALLENGE, embeddedPub)
|
const ED25519_SPKI_PREFIX = Uint8Array.from([
|
||||||
const sig = ed25519Sign(privateKey, message)
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||||
return concat(embeddedPub, sig)
|
])
|
||||||
}
|
const ED25519_SPKI_LEN = ED25519_SPKI_PREFIX.length + 32 // 44
|
||||||
|
const RAW_ED25519_LEN = 32
|
||||||
|
|
||||||
export interface CsrParts {
|
// --- minimal DER encoding helpers (test/agent-side CSR construction) ---------------------------
|
||||||
readonly embeddedPub: Uint8Array
|
|
||||||
readonly sig: Uint8Array
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse the modelled CSR bytes. Throws on wrong length. */
|
function derLen(len: number): Uint8Array {
|
||||||
export function parseCsr(csr: Uint8Array): CsrParts {
|
if (len < 0x80) return Uint8Array.from([len])
|
||||||
if (csr.length !== 96) throw new Error('malformed csr')
|
const bytes: number[] = []
|
||||||
return { embeddedPub: csr.subarray(0, 32), sig: csr.subarray(32, 96) }
|
let n = len
|
||||||
}
|
while (n > 0) {
|
||||||
|
bytes.unshift(n & 0xff)
|
||||||
/**
|
n >>= 8
|
||||||
* Verify CSR proof-of-possession: the embedded pubkey's private key signed the challenge.
|
|
||||||
* Independent of any registry state (a forged signature is refused even for a registered key).
|
|
||||||
*/
|
|
||||||
export function verifyCsrPoP(csr: Uint8Array): { ok: boolean; embeddedPub: Uint8Array } {
|
|
||||||
let parts: CsrParts
|
|
||||||
try {
|
|
||||||
parts = parseCsr(csr)
|
|
||||||
} catch {
|
|
||||||
return { ok: false, embeddedPub: new Uint8Array(0) }
|
|
||||||
}
|
}
|
||||||
const message = concat(CSR_CHALLENGE, parts.embeddedPub)
|
return Uint8Array.from([0x80 | bytes.length, ...bytes])
|
||||||
const ok = ed25519Verify(parts.embeddedPub, message, parts.sig)
|
|
||||||
return { ok, embeddedPub: parts.embeddedPub }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
|
function tlv(tag: number, value: Uint8Array): Uint8Array {
|
||||||
const out = new Uint8Array(a.length + b.length)
|
const len = derLen(value.length)
|
||||||
out.set(a, 0)
|
const out = new Uint8Array(1 + len.length + value.length)
|
||||||
out.set(b, a.length)
|
out[0] = tag
|
||||||
|
out.set(len, 1)
|
||||||
|
out.set(value, 1 + len.length)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||||||
|
const out = new Uint8Array(chunks.reduce((s, c) => s + c.length, 0))
|
||||||
|
let off = 0
|
||||||
|
for (const c of chunks) {
|
||||||
|
out.set(c, off)
|
||||||
|
off += c.length
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEQUENCE = 0x30
|
||||||
|
const SET = 0x31
|
||||||
|
const INTEGER = 0x02
|
||||||
|
const BIT_STRING = 0x03
|
||||||
|
const OID = 0x06
|
||||||
|
const UTF8_STRING = 0x0c
|
||||||
|
const CONTEXT_0 = 0xa0
|
||||||
|
const OID_CN = Uint8Array.from([0x55, 0x04, 0x03]) // 2.5.4.3 commonName
|
||||||
|
const OID_ED25519 = Uint8Array.from([0x2b, 0x65, 0x70]) // 1.3.101.112 Ed25519
|
||||||
|
|
||||||
|
function spkiFromRawEd25519(raw: Uint8Array): Uint8Array {
|
||||||
|
const algId = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||||
|
const pubBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), raw]))
|
||||||
|
return tlv(SEQUENCE, concat([algId, pubBits]))
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameFromCn(cn: string): Uint8Array {
|
||||||
|
const atv = tlv(SEQUENCE, concat([tlv(OID, OID_CN), tlv(UTF8_STRING, new TextEncoder().encode(cn))]))
|
||||||
|
return tlv(SEQUENCE, tlv(SET, atv))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a real PKCS#10 `CertificationRequest` DER for `embeddedPub`, self-signed by `privateKey`
|
||||||
|
* (Ed25519). Mirrors the agent's on-wire request shape (subject CN, empty attributes). Used by the
|
||||||
|
* control-plane's own tests to exercise the real parse/verify path; production requests come from
|
||||||
|
* the agent unchanged.
|
||||||
|
*/
|
||||||
|
export function buildCsr(privateKey: KeyObject, embeddedPub: Uint8Array): Uint8Array {
|
||||||
|
const version = tlv(INTEGER, Uint8Array.from([0x00]))
|
||||||
|
const requestInfo = tlv(
|
||||||
|
SEQUENCE,
|
||||||
|
concat([version, nameFromCn('web-terminal-agent'), spkiFromRawEd25519(embeddedPub), tlv(CONTEXT_0, new Uint8Array(0))]),
|
||||||
|
)
|
||||||
|
const signature = ed25519Sign(privateKey, requestInfo)
|
||||||
|
const sigAlg = tlv(SEQUENCE, tlv(OID, OID_ED25519))
|
||||||
|
const sigBits = tlv(BIT_STRING, concat([Uint8Array.from([0x00]), signature]))
|
||||||
|
return tlv(SEQUENCE, concat([requestInfo, sigAlg, sigBits]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wire decoding + verification --------------------------------------------------------------
|
||||||
|
|
||||||
|
const PEM_CSR_RE = /-----BEGIN CERTIFICATE REQUEST-----([\s\S]+?)-----END CERTIFICATE REQUEST-----/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode the `csr` field as it arrives on the `/enroll` wire into raw PKCS#10 DER bytes. Accepts
|
||||||
|
* BOTH shapes the codebase produces: the agent sends a PEM `CERTIFICATE REQUEST` block, while the
|
||||||
|
* control-plane's own HTTP tests send `base64(DER)`. Both normalise to the same DER — this is the
|
||||||
|
* single place the wire encoding is interpreted (boundary validation, coding-style §Input).
|
||||||
|
*/
|
||||||
|
export function decodeCsrWire(wire: string): Uint8Array {
|
||||||
|
const pem = PEM_CSR_RE.exec(wire)
|
||||||
|
const body = pem !== null ? pem[1]!.replace(/\s+/g, '') : wire.trim()
|
||||||
|
return new Uint8Array(Buffer.from(body, 'base64'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the raw 32-byte Ed25519 key from a SubjectPublicKeyInfo DER, or null if not Ed25519. */
|
||||||
|
function rawEd25519FromSpki(spki: Uint8Array): Uint8Array | null {
|
||||||
|
if (spki.length !== ED25519_SPKI_LEN) return null
|
||||||
|
for (let i = 0; i < ED25519_SPKI_PREFIX.length; i++) {
|
||||||
|
if (spki[i] !== ED25519_SPKI_PREFIX[i]) return null
|
||||||
|
}
|
||||||
|
return spki.subarray(ED25519_SPKI_PREFIX.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify CSR proof-of-possession: parse the PKCS#10 DER and check its self-signature against the
|
||||||
|
* embedded public key. Returns the embedded raw Ed25519 pubkey on success. FAIL-CLOSED and uniform:
|
||||||
|
* malformed DER, a non-Ed25519 key, or a bad signature all yield `{ ok: false, embeddedPub: [] }`
|
||||||
|
* with no leak of which check failed. Independent of any registry state.
|
||||||
|
*/
|
||||||
|
export async function verifyCsrPoP(csr: Uint8Array): Promise<{ ok: boolean; embeddedPub: Uint8Array }> {
|
||||||
|
const fail = { ok: false, embeddedPub: new Uint8Array(0) }
|
||||||
|
try {
|
||||||
|
const req = new x509.Pkcs10CertificateRequest(toArrayBuffer(csr))
|
||||||
|
const embeddedPub = rawEd25519FromSpki(new Uint8Array(req.publicKey.rawData))
|
||||||
|
if (embeddedPub === null || embeddedPub.length !== RAW_ED25519_LEN) return fail
|
||||||
|
const ok = await req.verify()
|
||||||
|
return ok ? { ok: true, embeddedPub: new Uint8Array(embeddedPub) } : fail
|
||||||
|
} catch {
|
||||||
|
return fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
125
control-plane/src/ca/issue.ts
Normal file
125
control-plane/src/ca/issue.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Real X.509 leaf issuance (INV14). After the shared `assertLeafGate` passes, emit an X.509 v3
|
||||||
|
* Ed25519 leaf whose subject key is the enrolled agent pubkey and whose only SAN is the host's
|
||||||
|
* SPIFFE-ID URI — signed by the intermediate Ed25519 key. This is what `relay-auth`'s
|
||||||
|
* `verifyAgentCert` accepts (it walks leaf → intermediate → self-signed root and parses the
|
||||||
|
* `URI:spiffe://relay.<domain>/account/<a>/host/<h>` SAN).
|
||||||
|
*
|
||||||
|
* The SPIFFE-ID is built with relay-auth's OWN builder (`spiffeIdFor`, deep-imported) so the emitted
|
||||||
|
* SAN can never drift from the verifier's parser. The intermediate PRIVATE key is a WebCrypto
|
||||||
|
* `CryptoKey` imported non-extractable (never serialised back out — INV9).
|
||||||
|
*
|
||||||
|
* `reflect-metadata` must load before `@peculiar/x509` (tsyringe polyfill) — keep it first.
|
||||||
|
*/
|
||||||
|
import 'reflect-metadata'
|
||||||
|
import * as x509 from '@peculiar/x509'
|
||||||
|
import { webcrypto, randomBytes } from 'node:crypto'
|
||||||
|
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||||
|
import type { HostStore } from '../store/ports.js'
|
||||||
|
import { assertLeafGate, DEFAULT_LEAF_TTL_SEC, type LeafSigner } from './sign.js'
|
||||||
|
|
||||||
|
x509.cryptoProvider.set(webcrypto)
|
||||||
|
|
||||||
|
/** Backdate notBefore slightly to tolerate small clock skew between control-plane and relay. */
|
||||||
|
const CLOCK_SKEW_SEC = 60
|
||||||
|
|
||||||
|
export interface RealLeafSignerDeps {
|
||||||
|
readonly hosts: HostStore
|
||||||
|
/** Intermediate Ed25519 PRIVATE signing key (WebCrypto, non-extractable). */
|
||||||
|
readonly intermediateKey: CryptoKey
|
||||||
|
/** Intermediate subject as a Name — used verbatim as the leaf issuer so `checkIssued` matches. */
|
||||||
|
readonly issuerName: x509.Name
|
||||||
|
/** DER of [intermediate, root] returned to the agent as its CA bundle (INV14). */
|
||||||
|
readonly caChainDer: readonly Uint8Array[]
|
||||||
|
/** Bare trust domain; the SPIFFE builder prepends `relay.`. */
|
||||||
|
readonly trustDomain: string
|
||||||
|
readonly leafTtlSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import a raw 32-byte Ed25519 public key as a verifying WebCrypto CryptoKey (via SPKI DER). */
|
||||||
|
async function importEd25519Public(raw: Uint8Array): Promise<CryptoKey> {
|
||||||
|
const prefix = Uint8Array.from([
|
||||||
|
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
||||||
|
])
|
||||||
|
const spki = new Uint8Array(prefix.length + raw.length)
|
||||||
|
spki.set(prefix, 0)
|
||||||
|
spki.set(raw, prefix.length)
|
||||||
|
return webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the production leaf signer. Every issued leaf: X.509 v3, Ed25519 subject = agentPubkey,
|
||||||
|
* URI SAN = the host's SPIFFE-ID, CA:false, KeyUsage digitalSignature, EKU clientAuth, validity
|
||||||
|
* [now-skew, now+ttl]. Returns leaf DER + the injected CA chain DER; `redeem.ts` PEM-wraps both.
|
||||||
|
*/
|
||||||
|
export function createRealLeafSigner(deps: RealLeafSignerDeps): LeafSigner {
|
||||||
|
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||||
|
return {
|
||||||
|
async signHostLeaf(hostId, agentPubkey, csr) {
|
||||||
|
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
|
||||||
|
const spiffe = spiffeIdFor(host.accountId, host.hostId, deps.trustDomain)
|
||||||
|
const subjectKey = await importEd25519Public(agentPubkey)
|
||||||
|
const now = Date.now()
|
||||||
|
const leaf = await x509.X509CertificateGenerator.create({
|
||||||
|
serialNumber: randomBytes(16).toString('hex'),
|
||||||
|
subject: `CN=${host.hostId}`,
|
||||||
|
issuer: deps.issuerName,
|
||||||
|
notBefore: new Date(now - CLOCK_SKEW_SEC * 1000),
|
||||||
|
notAfter: new Date(now + ttl * 1000),
|
||||||
|
publicKey: subjectKey,
|
||||||
|
signingKey: deps.intermediateKey,
|
||||||
|
signingAlgorithm: { name: 'Ed25519' },
|
||||||
|
extensions: [
|
||||||
|
new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }]),
|
||||||
|
new x509.BasicConstraintsExtension(false, undefined, true),
|
||||||
|
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
||||||
|
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return { cert: new Uint8Array(leaf.rawData), caChain: deps.caChainDer }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadRealLeafSignerInput {
|
||||||
|
readonly hosts: HostStore
|
||||||
|
/** Intermediate Ed25519 private key, PKCS#8 PEM. */
|
||||||
|
readonly intermediateKeyPem: string
|
||||||
|
/** Intermediate certificate, PEM (single block). */
|
||||||
|
readonly intermediateCertPem: string
|
||||||
|
/** Self-signed root certificate, PEM (single block). */
|
||||||
|
readonly rootCertPem: string
|
||||||
|
readonly trustDomain: string
|
||||||
|
readonly leafTtlSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function pemToDer(pem: string): ArrayBuffer {
|
||||||
|
const body = pem.replace(/-----BEGIN [^-]+-----/g, '').replace(/-----END [^-]+-----/g, '').replace(/\s+/g, '')
|
||||||
|
const bytes = Buffer.from(body, 'base64')
|
||||||
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construct a real leaf signer from PEM material (boot path). Imports the intermediate private key
|
||||||
|
* (non-extractable — never re-serialised, INV9) and derives the issuer Name + CA chain DER from the
|
||||||
|
* certs. THROWS on unreadable/malformed material so the control-plane fails fast at boot.
|
||||||
|
*/
|
||||||
|
export async function loadRealLeafSigner(input: LoadRealLeafSignerInput): Promise<LeafSigner> {
|
||||||
|
const intermediateKey = await webcrypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
pemToDer(input.intermediateKeyPem),
|
||||||
|
{ name: 'Ed25519' },
|
||||||
|
false, // non-extractable: the raw private key can never leave the process (INV9)
|
||||||
|
['sign'],
|
||||||
|
)
|
||||||
|
const intermediateCert = new x509.X509Certificate(input.intermediateCertPem)
|
||||||
|
const rootCert = new x509.X509Certificate(input.rootCertPem)
|
||||||
|
return createRealLeafSigner({
|
||||||
|
hosts: input.hosts,
|
||||||
|
intermediateKey,
|
||||||
|
issuerName: intermediateCert.subjectName,
|
||||||
|
caChainDer: [new Uint8Array(intermediateCert.rawData), new Uint8Array(rootCert.rawData)],
|
||||||
|
trustDomain: input.trustDomain,
|
||||||
|
...(input.leafTtlSec !== undefined ? { leafTtlSec: input.leafTtlSec } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export function createLeafRenewer(deps: LeafRenewerDeps): LeafRenewer {
|
|||||||
// A revoked/absent host cannot renew (INV12 + INV14).
|
// A revoked/absent host cannot renew (INV12 + INV14).
|
||||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||||
|
|
||||||
const pop = verifyCsrPoP(csr)
|
const pop = await verifyCsrPoP(csr)
|
||||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||||
// embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal).
|
// embedded pubkey must equal the host's REGISTERED pubkey (no key substitution on renewal).
|
||||||
if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected')
|
if (!timingSafeEqualBytes(pop.embeddedPub, host.agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* T8 — bind-time mTLS leaf signer, registry-gated (INV14 registry half). ORDER OF CHECKS (all
|
* T8 — bind-time mTLS leaf signing, registry-gated (INV14 registry half). ORDER OF CHECKS (all
|
||||||
* must pass, SAME reject path):
|
* must pass, SAME reject path):
|
||||||
* 1. CSR proof-of-possession — verify the CSR self-signature against its embedded pubkey.
|
* 1. CSR proof-of-possession — verify the PKCS#10 self-signature against its embedded pubkey.
|
||||||
* 2. embedded pubkey == caller-supplied `agentPubkey` (no substitution).
|
* 2. embedded pubkey == caller-supplied `agentPubkey` (no substitution).
|
||||||
* 3. (hostId, agentPubkey) is an ACTIVE, non-revoked row in the host registry.
|
* 3. (hostId, agentPubkey) is an ACTIVE, non-revoked row in the host registry.
|
||||||
* A failure at ANY step rejects identically; the KMS `sign()` is NEVER invoked when any check
|
* A failure at ANY step rejects identically; issuance is NEVER reached when any check fails.
|
||||||
* fails. Signing itself is `CaSigner.sign()` (KMS, §3.1) — never a raw private key in memory.
|
*
|
||||||
|
* Two `LeafSigner` implementations share the `assertLeafGate` gate below:
|
||||||
|
* - `createLeafSigner` (this file) — DEV/legacy placeholder cert (a signed JSON blob, NOT X.509);
|
||||||
|
* used only when no real CA material is configured (see `main.ts` fallback).
|
||||||
|
* - `createRealLeafSigner` (`ca/issue.ts`) — the production issuer that emits a real X.509 v3
|
||||||
|
* Ed25519 leaf with a SPIFFE SAN, signed by the intermediate key.
|
||||||
*/
|
*/
|
||||||
import type { HostStore } from '../store/ports.js'
|
import type { HostStore } from '../store/ports.js'
|
||||||
|
import type { HostRecord } from '../model/records.js'
|
||||||
import type { CaSigner } from '../boot/ca-wiring.js'
|
import type { CaSigner } from '../boot/ca-wiring.js'
|
||||||
import { verifyCsrPoP } from './csr.js'
|
import { verifyCsrPoP } from './csr.js'
|
||||||
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
import { timingSafeEqualBytes, bytesToBase64 } from '../util/bytes.js'
|
||||||
@@ -18,14 +24,6 @@ export class LeafSignError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeafSignerDeps {
|
|
||||||
readonly hosts: HostStore
|
|
||||||
readonly signer: CaSigner
|
|
||||||
readonly caChainDer: readonly Uint8Array[]
|
|
||||||
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
|
|
||||||
readonly leafTtlSec?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeafSigner {
|
export interface LeafSigner {
|
||||||
signHostLeaf(
|
signHostLeaf(
|
||||||
hostId: string,
|
hostId: string,
|
||||||
@@ -34,28 +32,56 @@ export interface LeafSigner {
|
|||||||
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
): Promise<{ cert: Uint8Array; caChain: readonly Uint8Array[] }>
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
/** Leaf validity in seconds (short-lived, INV14). Default 24h. */
|
||||||
|
export const DEFAULT_LEAF_TTL_SEC = 24 * 60 * 60
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared registry + proof-of-possession gate (INV14). Runs the three ordered checks and, on
|
||||||
|
* success, returns the gated host record so the issuer can build the SPIFFE SAN from the
|
||||||
|
* authoritative `accountId`/`hostId` binding. Throws `LeafSignError` (uniform) on any failure.
|
||||||
|
*/
|
||||||
|
export async function assertLeafGate(
|
||||||
|
hosts: HostStore,
|
||||||
|
hostId: string,
|
||||||
|
agentPubkey: Uint8Array,
|
||||||
|
csr: Uint8Array,
|
||||||
|
): Promise<HostRecord> {
|
||||||
|
// 1. proof-of-possession (independent of registry state)
|
||||||
|
const pop = await verifyCsrPoP(csr)
|
||||||
|
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
||||||
|
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
|
||||||
|
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
|
||||||
|
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
|
||||||
|
const host = await hosts.get(hostId)
|
||||||
|
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
||||||
|
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeafSignerDeps {
|
||||||
|
readonly hosts: HostStore
|
||||||
|
readonly signer: CaSigner
|
||||||
|
readonly caChainDer: readonly Uint8Array[]
|
||||||
|
readonly leafTtlSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEV/legacy signer — emits a signed JSON placeholder (NOT real X.509). Retained so tests and dev
|
||||||
|
* boots without configured CA material still exercise the gate/reject path. Production uses
|
||||||
|
* `createRealLeafSigner` (`ca/issue.ts`).
|
||||||
|
*/
|
||||||
export function createLeafSigner(deps: LeafSignerDeps): LeafSigner {
|
export function createLeafSigner(deps: LeafSignerDeps): LeafSigner {
|
||||||
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
const ttl = deps.leafTtlSec ?? DEFAULT_LEAF_TTL_SEC
|
||||||
return {
|
return {
|
||||||
async signHostLeaf(hostId, agentPubkey, csr) {
|
async signHostLeaf(hostId, agentPubkey, csr) {
|
||||||
// 1. proof-of-possession (independent of registry state)
|
const host = await assertLeafGate(deps.hosts, hostId, agentPubkey, csr)
|
||||||
const pop = verifyCsrPoP(csr)
|
|
||||||
if (!pop.ok) throw new LeafSignError('csr_rejected')
|
|
||||||
// 2. no substitution: CSR pubkey must equal the presented agentPubkey
|
|
||||||
if (!timingSafeEqualBytes(pop.embeddedPub, agentPubkey)) throw new LeafSignError('csr_rejected')
|
|
||||||
// 3. registry gate: host bound, active, non-revoked, pubkey matches (INV14)
|
|
||||||
const host = await deps.hosts.get(hostId)
|
|
||||||
if (host === null || host.status === 'revoked') throw new LeafSignError('not_registered')
|
|
||||||
if (!timingSafeEqualBytes(host.agentPubkey, agentPubkey)) throw new LeafSignError('not_registered')
|
|
||||||
|
|
||||||
// Only now do we invoke KMS sign() over the to-be-signed leaf.
|
// Only now do we invoke KMS sign() over the to-be-signed placeholder.
|
||||||
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
const notAfter = Math.floor(Date.now() / 1000) + ttl
|
||||||
const tbs = new TextEncoder().encode(
|
const tbs = new TextEncoder().encode(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
v: 1,
|
v: 1,
|
||||||
hostId,
|
hostId: host.hostId,
|
||||||
subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests)
|
subjectSpki: bytesToBase64(agentPubkey), // subject pubkey == agentPubkey (assertable in tests)
|
||||||
notAfter,
|
notAfter,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* live KMS call; `loadEnv` validates only the presence/shape of the ref here.
|
* live KMS call; `loadEnv` validates only the presence/shape of the ref here.
|
||||||
*/
|
*/
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
import { base64ToBytes } from './util/bytes.js'
|
import { base64ToBytes } from './util/bytes.js'
|
||||||
|
|
||||||
/** Pairing-code TTL default: 10 minutes (§5 T7). */
|
/** Pairing-code TTL default: 10 minutes (§5 T7). */
|
||||||
@@ -24,6 +25,16 @@ export interface ControlPlaneEnv {
|
|||||||
readonly caIntermediateKmsKeyRef: string
|
readonly caIntermediateKmsKeyRef: string
|
||||||
/** Intermediate cert (public) + chain up to the offline root. */
|
/** Intermediate cert (public) + chain up to the offline root. */
|
||||||
readonly caIntermediateCertPath: string
|
readonly caIntermediateCertPath: string
|
||||||
|
/**
|
||||||
|
* Intermediate Ed25519 PRIVATE key (PKCS#8 PEM) used by the REAL leaf issuer. Optional: when the
|
||||||
|
* file is present the control-plane issues real X.509 leaves; when absent it falls back to the
|
||||||
|
* dev placeholder signer. Defaults to `caIntermediateCertPath` with `.cert.pem`/`.pem` → `.key.pem`.
|
||||||
|
*/
|
||||||
|
readonly caIntermediateKeyPath: string
|
||||||
|
/** Self-signed root cert (PEM) for the agent CA bundle. Defaults to a sibling `root.cert.pem`. */
|
||||||
|
readonly caRootCertPath: string
|
||||||
|
/** Bare trust domain for the SPIFFE SAN (`spiffe://relay.<domain>/...`). */
|
||||||
|
readonly relayTrustDomain: string
|
||||||
/** CA bundle used to VERIFY relay-node mTLS client certs (T9 node-auth). */
|
/** CA bundle used to VERIFY relay-node mTLS client certs (T9 node-auth). */
|
||||||
readonly nodeMtlsTrustBundlePath: string
|
readonly nodeMtlsTrustBundlePath: string
|
||||||
/** 'term.<domain>' for subdomain assembly. */
|
/** 'term.<domain>' for subdomain assembly. */
|
||||||
@@ -45,12 +56,22 @@ const intWithDefault = (fallback: number) =>
|
|||||||
const requiredString = (name: string) =>
|
const requiredString = (name: string) =>
|
||||||
z.string({ required_error: `${name} is required` }).trim().min(1, `${name} must not be empty`)
|
z.string({ required_error: `${name} is required` }).trim().min(1, `${name} must not be empty`)
|
||||||
|
|
||||||
|
/** Default intermediate KEY path from its CERT path: `*.cert.pem`/`*.pem` → `*.key.pem`. */
|
||||||
|
function deriveKeyPath(certPath: string): string {
|
||||||
|
if (certPath.endsWith('.cert.pem')) return certPath.slice(0, -'.cert.pem'.length) + '.key.pem'
|
||||||
|
if (certPath.endsWith('.pem')) return certPath.slice(0, -'.pem'.length) + '.key.pem'
|
||||||
|
return certPath + '.key.pem'
|
||||||
|
}
|
||||||
|
|
||||||
const EnvSchema = z.object({
|
const EnvSchema = z.object({
|
||||||
PG_URL: requiredString('PG_URL').url('PG_URL must be a valid connection URL'),
|
PG_URL: requiredString('PG_URL').url('PG_URL must be a valid connection URL'),
|
||||||
REDIS_URL: requiredString('REDIS_URL').url('REDIS_URL must be a valid connection URL'),
|
REDIS_URL: requiredString('REDIS_URL').url('REDIS_URL must be a valid connection URL'),
|
||||||
CAPABILITY_SIGN_PUBKEY_B64: requiredString('CAPABILITY_SIGN_PUBKEY_B64'),
|
CAPABILITY_SIGN_PUBKEY_B64: requiredString('CAPABILITY_SIGN_PUBKEY_B64'),
|
||||||
CA_INTERMEDIATE_KMS_KEY_REF: requiredString('CA_INTERMEDIATE_KMS_KEY_REF'),
|
CA_INTERMEDIATE_KMS_KEY_REF: requiredString('CA_INTERMEDIATE_KMS_KEY_REF'),
|
||||||
CA_INTERMEDIATE_CERT_PATH: requiredString('CA_INTERMEDIATE_CERT_PATH'),
|
CA_INTERMEDIATE_CERT_PATH: requiredString('CA_INTERMEDIATE_CERT_PATH'),
|
||||||
|
CA_INTERMEDIATE_KEY_PATH: z.string().trim().optional(),
|
||||||
|
CA_ROOT_CERT_PATH: z.string().trim().optional(),
|
||||||
|
RELAY_TRUST_DOMAIN: z.string().trim().optional(),
|
||||||
NODE_MTLS_TRUST_BUNDLE_PATH: requiredString('NODE_MTLS_TRUST_BUNDLE_PATH'),
|
NODE_MTLS_TRUST_BUNDLE_PATH: requiredString('NODE_MTLS_TRUST_BUNDLE_PATH'),
|
||||||
BASE_DOMAIN: requiredString('BASE_DOMAIN'),
|
BASE_DOMAIN: requiredString('BASE_DOMAIN'),
|
||||||
HEARTBEAT_TTL_SEC: intWithDefault(15),
|
HEARTBEAT_TTL_SEC: intWithDefault(15),
|
||||||
@@ -81,12 +102,24 @@ export function loadEnv(source: NodeJS.ProcessEnv): ControlPlaneEnv {
|
|||||||
if (capabilitySignPubkey.length !== 32) {
|
if (capabilitySignPubkey.length !== 32) {
|
||||||
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes (Ed25519)')
|
throw new Error('Invalid control-plane env: CAPABILITY_SIGN_PUBKEY_B64 must decode to 32 bytes (Ed25519)')
|
||||||
}
|
}
|
||||||
|
const caIntermediateKeyPath =
|
||||||
|
e.CA_INTERMEDIATE_KEY_PATH !== undefined && e.CA_INTERMEDIATE_KEY_PATH.length > 0
|
||||||
|
? e.CA_INTERMEDIATE_KEY_PATH
|
||||||
|
: deriveKeyPath(e.CA_INTERMEDIATE_CERT_PATH)
|
||||||
|
const caRootCertPath =
|
||||||
|
e.CA_ROOT_CERT_PATH !== undefined && e.CA_ROOT_CERT_PATH.length > 0
|
||||||
|
? e.CA_ROOT_CERT_PATH
|
||||||
|
: join(dirname(e.CA_INTERMEDIATE_CERT_PATH), 'root.cert.pem')
|
||||||
return {
|
return {
|
||||||
pgUrl: e.PG_URL,
|
pgUrl: e.PG_URL,
|
||||||
redisUrl: e.REDIS_URL,
|
redisUrl: e.REDIS_URL,
|
||||||
capabilitySignPubkey,
|
capabilitySignPubkey,
|
||||||
caIntermediateKmsKeyRef: e.CA_INTERMEDIATE_KMS_KEY_REF,
|
caIntermediateKmsKeyRef: e.CA_INTERMEDIATE_KMS_KEY_REF,
|
||||||
caIntermediateCertPath: e.CA_INTERMEDIATE_CERT_PATH,
|
caIntermediateCertPath: e.CA_INTERMEDIATE_CERT_PATH,
|
||||||
|
caIntermediateKeyPath,
|
||||||
|
caRootCertPath,
|
||||||
|
relayTrustDomain:
|
||||||
|
e.RELAY_TRUST_DOMAIN !== undefined && e.RELAY_TRUST_DOMAIN.length > 0 ? e.RELAY_TRUST_DOMAIN : 'example.com',
|
||||||
nodeMtlsTrustBundlePath: e.NODE_MTLS_TRUST_BUNDLE_PATH,
|
nodeMtlsTrustBundlePath: e.NODE_MTLS_TRUST_BUNDLE_PATH,
|
||||||
baseDomain: e.BASE_DOMAIN,
|
baseDomain: e.BASE_DOMAIN,
|
||||||
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
heartbeatTtlSec: e.HEARTBEAT_TTL_SEC,
|
||||||
|
|||||||
@@ -12,9 +12,10 @@
|
|||||||
* Ed25519 signer (DEV ONLY — NOT a real KMS).
|
* Ed25519 signer (DEV ONLY — NOT a real KMS).
|
||||||
*/
|
*/
|
||||||
import Fastify, { type FastifyInstance } from 'fastify'
|
import Fastify, { type FastifyInstance } from 'fastify'
|
||||||
|
import { existsSync, readFileSync } from 'node:fs'
|
||||||
import type { ControlPlaneEnv } from './env.js'
|
import type { ControlPlaneEnv } from './env.js'
|
||||||
import { createMemoryStores } from './store/memory.js'
|
import { createMemoryStores } from './store/memory.js'
|
||||||
import type { Stores } from './store/ports.js'
|
import type { Stores, HostStore } from './store/ports.js'
|
||||||
import { createAuditLog } from './audit/log.js'
|
import { createAuditLog } from './audit/log.js'
|
||||||
import { createAccountRegistry } from './registry/accounts.js'
|
import { createAccountRegistry } from './registry/accounts.js'
|
||||||
import { createHostRegistry } from './registry/hosts.js'
|
import { createHostRegistry } from './registry/hosts.js'
|
||||||
@@ -22,14 +23,15 @@ import { createSessionRegistry } from './registry/sessions.js'
|
|||||||
import { createSubdomainAssigner } from './subdomain/assign.js'
|
import { createSubdomainAssigner } from './subdomain/assign.js'
|
||||||
import { createPairingIssuer } from './pairing/issue.js'
|
import { createPairingIssuer } from './pairing/issue.js'
|
||||||
import { createPairingRedeemer } from './pairing/redeem.js'
|
import { createPairingRedeemer } from './pairing/redeem.js'
|
||||||
import { createLeafSigner } from './ca/sign.js'
|
import { createLeafSigner, type LeafSigner } from './ca/sign.js'
|
||||||
|
import { loadRealLeafSigner } from './ca/issue.js'
|
||||||
import { createRoutingTable } from './routing/table.js'
|
import { createRoutingTable } from './routing/table.js'
|
||||||
import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js'
|
import { createInMemoryRevocationBus, type TestableRevocationBus } from './routing/bus.js'
|
||||||
import { createMeteringCollector } from './metering/collect.js'
|
import { createMeteringCollector } from './metering/collect.js'
|
||||||
import { createDeprovisioner } from './deprovision/deprovision.js'
|
import { createDeprovisioner } from './deprovision/deprovision.js'
|
||||||
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
|
import { createAuthorizer, type CapabilityVerifier } from './api/authz.js'
|
||||||
import { buildRouter } from './api/provision.js'
|
import { buildRouter } from './api/provision.js'
|
||||||
import { buildCaSigner, inProcessCaSigner, type KmsResolver } from './boot/ca-wiring.js'
|
import { buildCaSigner, inProcessCaSigner, type KmsResolver, type CaSigner } from './boot/ca-wiring.js'
|
||||||
import { configureCapabilityVerifyKey } from './boot/verifier.js'
|
import { configureCapabilityVerifyKey } from './boot/verifier.js'
|
||||||
import type { RevocationBus } from 'relay-contracts'
|
import type { RevocationBus } from 'relay-contracts'
|
||||||
|
|
||||||
@@ -52,6 +54,36 @@ const refuseAllVerifier: CapabilityVerifier = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Choose the leaf signer. When the intermediate PRIVATE key file is present on disk, issue REAL
|
||||||
|
* X.509 leaves (production); when absent, fall back to the dev placeholder signer so tests and
|
||||||
|
* key-less dev boots still run. A present-but-unreadable/malformed key FAILS FAST (INV9).
|
||||||
|
*/
|
||||||
|
async function buildLeafSigner(
|
||||||
|
env: ControlPlaneEnv,
|
||||||
|
hosts: HostStore,
|
||||||
|
caSigner: CaSigner,
|
||||||
|
caChainDer: readonly Uint8Array[],
|
||||||
|
): Promise<LeafSigner> {
|
||||||
|
if (!existsSync(env.caIntermediateKeyPath)) {
|
||||||
|
return createLeafSigner({ hosts, signer: caSigner, caChainDer })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await loadRealLeafSigner({
|
||||||
|
hosts,
|
||||||
|
intermediateKeyPem: readFileSync(env.caIntermediateKeyPath, 'utf8'),
|
||||||
|
intermediateCertPem: readFileSync(env.caIntermediateCertPath, 'utf8'),
|
||||||
|
rootCertPem: readFileSync(env.caRootCertPath, 'utf8'),
|
||||||
|
trustDomain: env.relayTrustDomain,
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Never echo key material — only that loading failed and which path shape was configured (INV9).
|
||||||
|
throw new Error(
|
||||||
|
`failed to load CA leaf-signing material: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
|
/** In-process KMS resolver — DEV ONLY. Production injects a real non-exportable KMS key (§3.1). */
|
||||||
function devKmsResolver(): KmsResolver {
|
function devKmsResolver(): KmsResolver {
|
||||||
const signer = inProcessCaSigner()
|
const signer = inProcessCaSigner()
|
||||||
@@ -77,7 +109,7 @@ export async function buildControlPlane(
|
|||||||
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
|
const subdomains = createSubdomainAssigner({ subdomains: stores.subdomains, audit })
|
||||||
|
|
||||||
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
|
const caSigner = await buildCaSigner(env, overrides.kmsResolver ?? devKmsResolver())
|
||||||
const leafSigner = createLeafSigner({ hosts: stores.hosts, signer: caSigner, caChainDer })
|
const leafSigner = await buildLeafSigner(env, stores.hosts, caSigner, caChainDer)
|
||||||
|
|
||||||
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
|
const pairingIssuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: env.pairingTtlSec, audit })
|
||||||
const redeemer = createPairingRedeemer({
|
const redeemer = createPairingRedeemer({
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function createPairingRedeemer(deps: RedeemDeps): PairingRedeemer {
|
|||||||
if (row.record.redeemedAt !== null) throw new RedeemError('already_redeemed')
|
if (row.record.redeemedAt !== null) throw new RedeemError('already_redeemed')
|
||||||
|
|
||||||
// CSR proof-of-possession + no-substitution. A failure counts toward the code-scoped lockout.
|
// CSR proof-of-possession + no-substitution. A failure counts toward the code-scoped lockout.
|
||||||
const pop = verifyCsrPoP(input.csr)
|
const pop = await verifyCsrPoP(input.csr)
|
||||||
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
|
if (!pop.ok || !timingSafeEqualBytes(pop.embeddedPub, input.agentPubkey)) {
|
||||||
await deps.pairing.registerFailure(codeHash)
|
await deps.pairing.registerFailure(codeHash)
|
||||||
throw new RedeemError('bad_csr')
|
throw new RedeemError('bad_csr')
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe('T8 signHostLeaf — INV14 registry-gated + CSR PoP', () => {
|
|||||||
const forged = new Uint8Array(96)
|
const forged = new Uint8Array(96)
|
||||||
forged.set(publicKeyRaw, 0)
|
forged.set(publicKeyRaw, 0)
|
||||||
forged.set(randomBytes(64), 32)
|
forged.set(randomBytes(64), 32)
|
||||||
expect(verifyCsrPoP(forged).ok).toBe(false)
|
expect((await verifyCsrPoP(forged)).ok).toBe(false)
|
||||||
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
|
await expect(signer.signHostLeaf(host.hostId, publicKeyRaw, forged)).rejects.toBeInstanceOf(LeafSignError)
|
||||||
expect(signSpy).not.toHaveBeenCalled()
|
expect(signSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
220
control-plane/test/interop.test.ts
Normal file
220
control-plane/test/interop.test.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* OBJECTIVE ORACLE (INV4/INV14) — proves the control-plane's REAL leaf issuer emits an X.509 leaf
|
||||||
|
* that relay-auth's `verifyAgentCert` accepts, WITHOUT deploying either service.
|
||||||
|
*
|
||||||
|
* A throwaway Ed25519 root→intermediate chain is generated in-test; a leaf is issued through
|
||||||
|
* `createRealLeafSigner` for a random account/host + agent pubkey; then relay-auth verifies the
|
||||||
|
* leaf against the intermediate+root bundle with a fake host registry. Positive path asserts
|
||||||
|
* `{ ok: true, hostId, accountId }`; negatives assert the exact reject reasons.
|
||||||
|
*/
|
||||||
|
import 'reflect-metadata'
|
||||||
|
import { describe, test, expect } from 'vitest'
|
||||||
|
import * as x509 from '@peculiar/x509'
|
||||||
|
import { webcrypto, randomUUID } from 'node:crypto'
|
||||||
|
import { verifyAgentCert, defaultParseX509 } from 'relay-auth/src/agent/verify-mtls.js'
|
||||||
|
import { spiffeIdFor } from 'relay-auth/src/agent/spiffe.js'
|
||||||
|
import type { HostRecord } from 'relay-contracts'
|
||||||
|
import { createMemoryStores } from '../src/store/memory.js'
|
||||||
|
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||||
|
import { createRealLeafSigner, loadRealLeafSigner } from '../src/ca/issue.js'
|
||||||
|
import { buildCsr } from '../src/ca/csr.js'
|
||||||
|
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||||
|
import { generateEd25519 } from '../src/util/crypto.js'
|
||||||
|
|
||||||
|
x509.cryptoProvider.set(webcrypto)
|
||||||
|
|
||||||
|
const TRUST_DOMAIN = 'example.com'
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
interface Ca {
|
||||||
|
readonly intKey: CryptoKey
|
||||||
|
readonly issuerName: x509.Name
|
||||||
|
readonly caChainDer: readonly Uint8Array[]
|
||||||
|
readonly caChainPem: string
|
||||||
|
readonly intermediateKeyPem: string
|
||||||
|
readonly intermediateCertPem: string
|
||||||
|
readonly rootCertPem: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyPair = { readonly publicKey: CryptoKey; readonly privateKey: CryptoKey }
|
||||||
|
|
||||||
|
function derToPemLabeled(der: Uint8Array, label: string): string {
|
||||||
|
const b64 = Buffer.from(der).toString('base64').match(/.{1,64}/g)?.join('\n') ?? ''
|
||||||
|
return `-----BEGIN ${label}-----\n${b64}\n-----END ${label}-----\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeCa(): Promise<Ca> {
|
||||||
|
const subtle = webcrypto.subtle
|
||||||
|
const rootKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||||
|
const intKeys = (await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as KeyPair
|
||||||
|
const now = Date.now()
|
||||||
|
const rootCert = await x509.X509CertificateGenerator.createSelfSigned({
|
||||||
|
serialNumber: '01',
|
||||||
|
name: 'CN=relay-root',
|
||||||
|
notBefore: new Date(now - DAY_MS),
|
||||||
|
notAfter: new Date(now + DAY_MS),
|
||||||
|
keys: rootKeys,
|
||||||
|
signingAlgorithm: { name: 'Ed25519' },
|
||||||
|
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
|
||||||
|
})
|
||||||
|
const intCert = await x509.X509CertificateGenerator.create({
|
||||||
|
serialNumber: '02',
|
||||||
|
subject: 'CN=relay-intermediate',
|
||||||
|
issuer: rootCert.subjectName,
|
||||||
|
notBefore: new Date(now - DAY_MS),
|
||||||
|
notAfter: new Date(now + DAY_MS),
|
||||||
|
publicKey: intKeys.publicKey,
|
||||||
|
signingKey: rootKeys.privateKey,
|
||||||
|
signingAlgorithm: { name: 'Ed25519' },
|
||||||
|
extensions: [new x509.BasicConstraintsExtension(true, undefined, true)],
|
||||||
|
})
|
||||||
|
const intKeyPkcs8 = new Uint8Array(await subtle.exportKey('pkcs8', intKeys.privateKey))
|
||||||
|
return {
|
||||||
|
intKey: intKeys.privateKey,
|
||||||
|
issuerName: intCert.subjectName,
|
||||||
|
caChainDer: [new Uint8Array(intCert.rawData), new Uint8Array(rootCert.rawData)],
|
||||||
|
caChainPem: `${intCert.toString('pem')}\n${rootCert.toString('pem')}`,
|
||||||
|
intermediateKeyPem: derToPemLabeled(intKeyPkcs8, 'PRIVATE KEY'),
|
||||||
|
intermediateCertPem: intCert.toString('pem'),
|
||||||
|
rootCertPem: rootCert.toString('pem'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function derToPem(der: Uint8Array): string {
|
||||||
|
return new x509.X509Certificate(der).toString('pem')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal relay-contracts HostRecord for the fake registry (only accountId/status are read). */
|
||||||
|
function fakeHost(hostId: string, accountId: string, status: HostRecord['status']): HostRecord {
|
||||||
|
return {
|
||||||
|
hostId,
|
||||||
|
accountId,
|
||||||
|
subdomain: 'host',
|
||||||
|
agentPubkey: new Uint8Array(32),
|
||||||
|
enrollFpr: 'fpr',
|
||||||
|
status,
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
revokedAt: status === 'revoked' ? new Date().toISOString() : null,
|
||||||
|
} as HostRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
async function issueLeaf() {
|
||||||
|
const ca = await makeCa()
|
||||||
|
const stores = createMemoryStores()
|
||||||
|
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||||
|
const accountId = randomUUID()
|
||||||
|
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||||
|
const host = await hosts.bindHost({
|
||||||
|
accountId,
|
||||||
|
subdomain: 'alice',
|
||||||
|
agentPubkey: publicKeyRaw,
|
||||||
|
enrollFpr: fingerprint(publicKeyRaw),
|
||||||
|
})
|
||||||
|
const signer = createRealLeafSigner({
|
||||||
|
hosts: stores.hosts,
|
||||||
|
intermediateKey: ca.intKey,
|
||||||
|
issuerName: ca.issuerName,
|
||||||
|
caChainDer: ca.caChainDer,
|
||||||
|
trustDomain: TRUST_DOMAIN,
|
||||||
|
})
|
||||||
|
const { cert, caChain } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
|
||||||
|
return { ca, accountId, hostId: host.hostId, publicKeyRaw, leafPem: derToPem(cert), caChain }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('interop: control-plane real leaf ⇄ relay-auth verifyAgentCert', () => {
|
||||||
|
test('valid leaf for an active enrolled host → { ok: true, hostId, accountId }', async () => {
|
||||||
|
const { ca, accountId, hostId, leafPem, caChain } = await issueLeaf()
|
||||||
|
expect(caChain).toHaveLength(2) // intermediate + root DER
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||||
|
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res).toEqual({ ok: true, hostId, accountId })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SPIFFE accountId ≠ registry accountId → spiffe_account_mismatch', async () => {
|
||||||
|
const { ca, hostId, leafPem } = await issueLeaf()
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, randomUUID(), 'online') : null) }
|
||||||
|
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toBe('spiffe_account_mismatch')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('revoked host → host_revoked', async () => {
|
||||||
|
const { ca, accountId, hostId, leafPem } = await issueLeaf()
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'revoked') : null) }
|
||||||
|
const res = await verifyAgentCert(leafPem, ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toBe('host_revoked')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('expired leaf → expired', async () => {
|
||||||
|
const ca = await makeCa()
|
||||||
|
const accountId = randomUUID()
|
||||||
|
const hostId = randomUUID()
|
||||||
|
const { publicKeyRaw } = generateEd25519()
|
||||||
|
const spiffe = spiffeIdFor(accountId, hostId, TRUST_DOMAIN)
|
||||||
|
const prefix = Uint8Array.from([0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00])
|
||||||
|
const spki = new Uint8Array(44)
|
||||||
|
spki.set(prefix, 0)
|
||||||
|
spki.set(publicKeyRaw, 12)
|
||||||
|
const subjectKey = await webcrypto.subtle.importKey('spki', spki, { name: 'Ed25519' }, true, ['verify'])
|
||||||
|
const now = Date.now()
|
||||||
|
const expiredLeaf = await x509.X509CertificateGenerator.create({
|
||||||
|
serialNumber: '0e',
|
||||||
|
subject: `CN=${hostId}`,
|
||||||
|
issuer: ca.issuerName,
|
||||||
|
notBefore: new Date(now - 2 * DAY_MS),
|
||||||
|
notAfter: new Date(now - DAY_MS), // already expired
|
||||||
|
publicKey: subjectKey,
|
||||||
|
signingKey: ca.intKey,
|
||||||
|
signingAlgorithm: { name: 'Ed25519' },
|
||||||
|
extensions: [new x509.SubjectAlternativeNameExtension([{ type: 'url', value: spiffe }])],
|
||||||
|
})
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||||
|
const res = await verifyAgentCert(
|
||||||
|
expiredLeaf.toString('pem'),
|
||||||
|
ca.caChainPem,
|
||||||
|
Math.floor(Date.now() / 1000),
|
||||||
|
registry,
|
||||||
|
defaultParseX509,
|
||||||
|
)
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toBe('expired')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('boot path: loadRealLeafSigner (PEM material) issues a verifiable leaf', async () => {
|
||||||
|
const ca = await makeCa()
|
||||||
|
const stores = createMemoryStores()
|
||||||
|
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||||
|
const accountId = randomUUID()
|
||||||
|
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||||
|
const host = await hosts.bindHost({
|
||||||
|
accountId,
|
||||||
|
subdomain: 'boot',
|
||||||
|
agentPubkey: publicKeyRaw,
|
||||||
|
enrollFpr: fingerprint(publicKeyRaw),
|
||||||
|
})
|
||||||
|
const signer = await loadRealLeafSigner({
|
||||||
|
hosts: stores.hosts,
|
||||||
|
intermediateKeyPem: ca.intermediateKeyPem,
|
||||||
|
intermediateCertPem: ca.intermediateCertPem,
|
||||||
|
rootCertPem: ca.rootCertPem,
|
||||||
|
trustDomain: TRUST_DOMAIN,
|
||||||
|
})
|
||||||
|
const { cert } = await signer.signHostLeaf(host.hostId, publicKeyRaw, buildCsr(privateKey, publicKeyRaw))
|
||||||
|
const registry = {
|
||||||
|
getById: async (id: string) => (id === host.hostId ? fakeHost(host.hostId, accountId, 'online') : null),
|
||||||
|
}
|
||||||
|
const res = await verifyAgentCert(derToPem(cert), ca.caChainPem, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res).toEqual({ ok: true, hostId: host.hostId, accountId })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('intermediate-only bundle (no root) → chain_invalid', async () => {
|
||||||
|
const { ca, accountId, hostId, leafPem } = await issueLeaf()
|
||||||
|
const intermediateOnly = ca.caChainPem.split('-----END CERTIFICATE-----')[0]! + '-----END CERTIFICATE-----\n'
|
||||||
|
const registry = { getById: async (id: string) => (id === hostId ? fakeHost(hostId, accountId, 'online') : null) }
|
||||||
|
const res = await verifyAgentCert(leafPem, intermediateOnly, Math.floor(Date.now() / 1000), registry, defaultParseX509)
|
||||||
|
expect(res.ok).toBe(false)
|
||||||
|
expect(res.reason).toBe('chain_invalid')
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user