dsh-standards 1.4.0 → 1.5.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/40-checker/scripts/sign-evidence.mjs +169 -0
- package/40-checker/src/cli.mjs +394 -70
- package/40-checker/src/flow-evidence.mjs +21 -0
- package/40-checker/src/remediation.mjs +1 -1
- package/40-checker/src/report.mjs +49 -3
- package/40-checker/src/runtime-child.mjs +68 -0
- package/40-checker/src/runtime.mjs +3 -1
- package/40-checker/src/sandbox-run.mjs +406 -35
- package/40-checker/src/signature.mjs +249 -0
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/spec/schemas/conformance.schema.json +5 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ed25519 signatures for FLOW evidence (`dsh-flow-evidence/2`).
|
|
3
|
+
*
|
|
4
|
+
* Zero dependencies: `node:crypto` only. A signature is a detached Ed25519
|
|
5
|
+
* signature over the canonical JSON encoding of the document with the
|
|
6
|
+
* `signature` field removed. The chain hash is computed over the body (minus
|
|
7
|
+
* `chain` and `signature`); the signed payload includes `chain`, so the
|
|
8
|
+
* signature also binds the declared chain hash. Signing never changes
|
|
9
|
+
* `chain.evidenceHash`, because `signature` is excluded from both payloads.
|
|
10
|
+
*
|
|
11
|
+
* Signature record:
|
|
12
|
+
*
|
|
13
|
+
* signature: {
|
|
14
|
+
* alg: "ed25519",
|
|
15
|
+
* keyId: "<first 16 hex of sha256(SPKI public key PEM)>",
|
|
16
|
+
* publicKey: "<SPKI PEM>",
|
|
17
|
+
* sig: "<base64 detached signature>",
|
|
18
|
+
* at: "<ISO-8601 timestamp>"
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* `keyId` is derived from the exact SPKI PEM string that is embedded as
|
|
22
|
+
* `publicKey` (line endings included), so a verifier can recompute it from the
|
|
23
|
+
* signature record. This is an integrity/origin mechanism only: an embedded
|
|
24
|
+
* public key is self-asserted, so a valid signature does not make the signer
|
|
25
|
+
* trustworthy.
|
|
26
|
+
*/
|
|
27
|
+
import crypto from 'node:crypto'
|
|
28
|
+
|
|
29
|
+
export const SIGNATURE_ALG = 'ed25519'
|
|
30
|
+
export const SIGNATURE_SCHEMA = 'dsh-evidence-signature/1'
|
|
31
|
+
export const KEY_ID_LENGTH = 16
|
|
32
|
+
|
|
33
|
+
function isPlainObject(value) {
|
|
34
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** JSON with recursively sorted object keys; array order is preserved. */
|
|
38
|
+
export function canonicalJson(value) {
|
|
39
|
+
const sort = (node) => {
|
|
40
|
+
if (Array.isArray(node)) return node.map(sort)
|
|
41
|
+
if (isPlainObject(node)) {
|
|
42
|
+
const out = {}
|
|
43
|
+
for (const key of Object.keys(node).sort()) out[key] = sort(node[key])
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
return node
|
|
47
|
+
}
|
|
48
|
+
return JSON.stringify(sort(value))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The exact bytes an evidence signature covers: canonical JSON of every field
|
|
53
|
+
* except `signature`. Unlike `evidenceHash()` (which also removes `chain`),
|
|
54
|
+
* this includes `chain`, so a signature binds the declared chain hash as well.
|
|
55
|
+
*/
|
|
56
|
+
export function evidenceSigningPayload(document) {
|
|
57
|
+
if (!isPlainObject(document)) throw new TypeError('evidence document must be a JSON object')
|
|
58
|
+
const copy = { ...document }
|
|
59
|
+
delete copy.signature
|
|
60
|
+
return Buffer.from(canonicalJson(copy), 'utf8')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function asPublicKey(key) {
|
|
64
|
+
if (key instanceof crypto.KeyObject) {
|
|
65
|
+
if (key.type === 'public') return key
|
|
66
|
+
return crypto.createPublicKey(key)
|
|
67
|
+
}
|
|
68
|
+
return crypto.createPublicKey(key)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function asPrivateKey(key) {
|
|
72
|
+
if (key instanceof crypto.KeyObject) {
|
|
73
|
+
if (key.type === 'private') return key
|
|
74
|
+
return crypto.createPrivateKey(key)
|
|
75
|
+
}
|
|
76
|
+
return crypto.createPrivateKey(key)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function publicKeyPem(key) {
|
|
80
|
+
return asPublicKey(key).export({ type: 'spki', format: 'pem' }).toString()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** keyId = first 16 hex of sha256(SPKI public key PEM text). */
|
|
84
|
+
export function keyIdFromPublicKey(key) {
|
|
85
|
+
let pem
|
|
86
|
+
if (typeof key === 'string' && key.includes('BEGIN PUBLIC KEY')) {
|
|
87
|
+
pem = key
|
|
88
|
+
} else if (Buffer.isBuffer(key) && key.toString('utf8').includes('BEGIN PUBLIC KEY')) {
|
|
89
|
+
pem = key.toString('utf8')
|
|
90
|
+
} else {
|
|
91
|
+
pem = publicKeyPem(key)
|
|
92
|
+
}
|
|
93
|
+
return crypto.createHash('sha256').update(pem).digest('hex').slice(0, KEY_ID_LENGTH)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function generateEd25519KeyPair() {
|
|
97
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
|
|
98
|
+
return {
|
|
99
|
+
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
100
|
+
publicKeyPem: publicKeyPem(publicKey),
|
|
101
|
+
keyId: keyIdFromPublicKey(publicKey),
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function loadPrivateKey(pem) {
|
|
106
|
+
return crypto.createPrivateKey(pem)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function loadPublicKey(pem) {
|
|
110
|
+
return crypto.createPublicKey(pem)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Return a copy of `document` carrying an Ed25519 signature. Any previous
|
|
115
|
+
* signature is replaced (it is not part of the signed payload). `at` defaults
|
|
116
|
+
* to now; pass an explicit ISO timestamp for reproducible evidence.
|
|
117
|
+
*/
|
|
118
|
+
export function signEvidence(document, privateKey, { at = new Date().toISOString(), keyId = null } = {}) {
|
|
119
|
+
if (!isPlainObject(document)) throw new TypeError('evidence document must be a JSON object')
|
|
120
|
+
const privateObject = asPrivateKey(privateKey)
|
|
121
|
+
const publicObject = crypto.createPublicKey(privateObject)
|
|
122
|
+
const pem = publicKeyPem(publicObject)
|
|
123
|
+
const computed = keyIdFromPublicKey(pem)
|
|
124
|
+
const id = keyId ?? computed
|
|
125
|
+
if (typeof id !== 'string' || !/^[0-9a-f]{16}$/.test(id)) {
|
|
126
|
+
throw new Error('signature.keyId must be 16 lowercase hex characters')
|
|
127
|
+
}
|
|
128
|
+
if (id !== computed) {
|
|
129
|
+
throw new Error(`signature.keyId does not match the private key (expected ${computed})`)
|
|
130
|
+
}
|
|
131
|
+
if (typeof at !== 'string' || at.length === 0) {
|
|
132
|
+
throw new Error('signature.at must be a non-empty ISO-8601 string')
|
|
133
|
+
}
|
|
134
|
+
const sig = crypto.sign(null, evidenceSigningPayload(document), privateObject).toString('base64')
|
|
135
|
+
return {
|
|
136
|
+
...document,
|
|
137
|
+
signature: {
|
|
138
|
+
alg: SIGNATURE_ALG,
|
|
139
|
+
keyId: id,
|
|
140
|
+
publicKey: pem,
|
|
141
|
+
sig,
|
|
142
|
+
at,
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function invalid(reason, extra = {}) {
|
|
148
|
+
return { status: 'invalid', reason, ...extra }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Verify a signed evidence document.
|
|
153
|
+
*
|
|
154
|
+
* - `missing`: no `signature` (or `signature: null`), or no public key is
|
|
155
|
+
* available to check it (embedded key absent and no pinned key supplied).
|
|
156
|
+
* - `invalid`: malformed signature, key mismatch, or a payload that does not
|
|
157
|
+
* verify.
|
|
158
|
+
* - `valid`: the signature verifies against the embedded key (or `publicKey`
|
|
159
|
+
* when pinned).
|
|
160
|
+
*
|
|
161
|
+
* `publicKey` pins the trust anchor: pass a PEM/KeyObject out-of-band and the
|
|
162
|
+
* embedded key is only used to check `keyId` consistency. Without a pin, the
|
|
163
|
+
* embedded key is self-asserted — valid means "these bytes were signed by the
|
|
164
|
+
* holder of this key", not "the signer is trusted".
|
|
165
|
+
*/
|
|
166
|
+
export function verifyEvidenceSignature(document, { publicKey = null } = {}) {
|
|
167
|
+
if (!isPlainObject(document)) return invalid('evidence document must be a JSON object')
|
|
168
|
+
const signature = document.signature
|
|
169
|
+
if (signature === undefined || signature === null) {
|
|
170
|
+
return { status: 'missing', reason: 'document has no signature' }
|
|
171
|
+
}
|
|
172
|
+
if (!isPlainObject(signature)) return invalid('signature must be an object')
|
|
173
|
+
if (signature.alg !== SIGNATURE_ALG) return invalid(`signature.alg must be ${SIGNATURE_ALG}`)
|
|
174
|
+
if (typeof signature.sig !== 'string' || signature.sig.length === 0) {
|
|
175
|
+
return invalid('signature.sig must be a non-empty base64 string')
|
|
176
|
+
}
|
|
177
|
+
if (typeof signature.keyId !== 'string' || !/^[0-9a-f]{16}$/.test(signature.keyId)) {
|
|
178
|
+
return invalid('signature.keyId must be 16 lowercase hex characters')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let embedded = null
|
|
182
|
+
if (signature.publicKey !== undefined && signature.publicKey !== null) {
|
|
183
|
+
if (typeof signature.publicKey !== 'string' || signature.publicKey.trim() === '') {
|
|
184
|
+
return invalid('signature.publicKey must be a non-empty PEM string when present')
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
embedded = asPublicKey(signature.publicKey)
|
|
188
|
+
} catch {
|
|
189
|
+
return invalid('signature.publicKey is not a valid public key')
|
|
190
|
+
}
|
|
191
|
+
const embeddedId = keyIdFromPublicKey(signature.publicKey)
|
|
192
|
+
if (embeddedId !== signature.keyId) {
|
|
193
|
+
return invalid('signature.keyId does not match sha256(signature.publicKey)[:16]', {
|
|
194
|
+
keyId: embeddedId,
|
|
195
|
+
embeddedKeyId: embeddedId,
|
|
196
|
+
keyIdMatch: false,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let key = null
|
|
202
|
+
let source = null
|
|
203
|
+
if (publicKey !== null && publicKey !== undefined) {
|
|
204
|
+
try {
|
|
205
|
+
key = asPublicKey(publicKey)
|
|
206
|
+
} catch {
|
|
207
|
+
return invalid('pinned public key is not a valid public key')
|
|
208
|
+
}
|
|
209
|
+
source = 'pinned'
|
|
210
|
+
const pinnedId = keyIdFromPublicKey(key)
|
|
211
|
+
if (pinnedId !== signature.keyId) {
|
|
212
|
+
return invalid('signature.keyId does not match the pinned public key', {
|
|
213
|
+
keyId: pinnedId,
|
|
214
|
+
embeddedKeyId: embedded ? keyIdFromPublicKey(embedded) : null,
|
|
215
|
+
keyIdMatch: false,
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
} else if (embedded) {
|
|
219
|
+
key = embedded
|
|
220
|
+
source = 'embedded'
|
|
221
|
+
} else {
|
|
222
|
+
return {
|
|
223
|
+
status: 'missing',
|
|
224
|
+
reason: 'no public key: signature.publicKey is missing and no pinned public key was supplied',
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const derivedKeyId = keyIdFromPublicKey(key)
|
|
229
|
+
let ok = false
|
|
230
|
+
try {
|
|
231
|
+
ok = crypto.verify(null, evidenceSigningPayload(document), key, Buffer.from(signature.sig, 'base64'))
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return invalid(`signature verification failed: ${error.message}`, {
|
|
234
|
+
keyId: derivedKeyId,
|
|
235
|
+
keyIdMatch: true,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
const result = {
|
|
239
|
+
status: ok ? 'valid' : 'invalid',
|
|
240
|
+
alg: SIGNATURE_ALG,
|
|
241
|
+
keyId: derivedKeyId,
|
|
242
|
+
embeddedKeyId: embedded ? keyIdFromPublicKey(embedded) : null,
|
|
243
|
+
keyIdMatch: true,
|
|
244
|
+
publicKeySource: source,
|
|
245
|
+
at: typeof signature.at === 'string' ? signature.at : null,
|
|
246
|
+
}
|
|
247
|
+
if (!ok) result.reason = 'signature does not verify against the public key'
|
|
248
|
+
return result
|
|
249
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## 1.5.0 — 2026-11-07
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- 真实运行时检查:`--runtime`/`--host` 自动解析已装 dsh,在临时 HOME/DSH_HOME + env 白名单 + 超时硬杀下跑 DSC-04–09;报告新增 `runtimeChecks`(executed/modes/durationMs/isolation/checks)。
|
|
6
|
+
- Ed25519 证据签名:`sign-evidence.mjs keygen|sign|verify`(node:crypto,零依赖),`docs/SIGNING.md` 说明密钥与信任边界。
|
|
7
|
+
- 沙箱加固:`setpriv --no-new-privs --bounding-set=-all`、`nofile/fsize` rlimit、`unshare -Urm` + 只读 bind,记录 `hardening[]/hardeningDegraded[]`。
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- `incomplete` 只由「mandatory 且 not-run」触发;静态 `partial` 不再把 ds-c bundle 判成 incomplete(修复前 dsh-fast 静态 ds-c 为 incomplete/exit 2,修复后 pass/exit 0)。
|
|
12
|
+
- runtime-child 写 fd3 后立即退出,不再被插件 timers 拖到超时导致六项全 not-run。
|
|
13
|
+
- 裸 `--runtime` 对带入口 bundle 直接走 server-entry;无入口纯 patch bundle 仍需 `--host`。
|
|
14
|
+
- `conformance.schema.json` 加 `runtimeChecks`。
|
|
15
|
+
|
|
16
|
+
### Verified
|
|
17
|
+
|
|
18
|
+
- `npm test` 249/249;真实宿主 dsh 0.2.1-rc.5:dsh-fast/mcp-panel 的 DSC-04–09 出 P/NA/P/P/W/P,security-pack 20.9s 完成 G0–G3。
|
|
19
|
+
- Windows 11 + node 24.22.1 实测:check 结果与 WSL 逐字一致;sandbox 记 degraded(unshare/prlimit 缺失),`flow collect --sandbox` 因 `npm.cmd` 无法无 shell spawn 记 not-run。
|
|
20
|
+
|
|
21
|
+
## 1.4.1 — 2026-11-06
|
|
22
|
+
|
|
23
|
+
- 建议的 `docsUrl` 基址跟到 `dsh-plugin-guide@v0.4.6`(FLOW 示例修正版),不再指向 0.4.4。
|
|
24
|
+
|
|
1
25
|
## 1.4.0 — 2026-11-06
|
|
2
26
|
|
|
3
27
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-standards",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "DSH plugin standards and conformance checks: ENTRY/FLOW checks plus BOOST guidance. Non-official; no certification or ranking.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "40-checker/src/index.mjs",
|
|
@@ -73,6 +73,11 @@
|
|
|
73
73
|
"items": {
|
|
74
74
|
"$ref": "#/$defs/bucketRow"
|
|
75
75
|
}
|
|
76
|
+
},
|
|
77
|
+
"runtimeChecks": {
|
|
78
|
+
"type": "object",
|
|
79
|
+
"additionalProperties": true,
|
|
80
|
+
"description": "Additive block for explicit runtime runs (dsh-runtime-checks/1): executed, modes, durationMs, isolation, reason, failure, checks, entry."
|
|
76
81
|
}
|
|
77
82
|
},
|
|
78
83
|
"$defs": {
|