fix(core)!: the plugin artifact signature contract refuses any key that is not Ed25519 - #21534
Conversation
signPayload refuses a private key whose type is not Ed25519, and verifyPayload refuses a public key whose type is not the algorithm the signature's label names, so the ed25519 label is checked against the key instead of trusted. Both throw and name the key type found. Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…s, at sign time Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
…usal Claude-Session: https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check8 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cf1a7671946f5635f2a44588f6c365f21376e970 && git checkout cf1a7671946f5635f2a44588f6c365f21376e970
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 550f4cc2fd594f5965ef9b12da38538064ea9c9b f1f43680202c8d822145ce8d71b02a384b011022 && git checkout -B drift-repro 550f4cc2fd594f5965ef9b12da38538064ea9c9b && git merge --no-ff f1f43680202c8d822145ce8d71b02a384b011022
node scripts/docs-audit/affected-docs.mjs --json 550f4cc2fd594f5965ef9b12da38538064ea9c9b |
Fixes #21524
Clause-②: no (narrowing)
The plugin artifact signature contract in
@objectstack/coredeclares Ed25519 and now enforces it. Before this change,signPayloadandverifyPayloadhanded any key to node'ssign(null, …)/verify(null, …), and those follow the key. So an RSA, EC or Ed448 key signed under theed25519:KEYID:SIGlabel and verified against its own public half. Measured atad7c351898, through the module and five key input shapes: an RSA key produced a 342-character signature, EC 94 to 96, Ed448 152, and Ed25519 86. Every one was labelleded25519and every one verifiedtrue.What changed
packages/core/src/security/plugin-artifact-signature.ts:requireSignatureKeyType(key, alg, use). A key passes only whenkey.asymmetricKeyTypeIS the algorithm. Otherwise it throws a plainError, the module's existing style, naming the key type found (rsa,ec,ed448, orsecretfor a symmetric key).signPayloadcalls the check withSIGNATURE_ALGbefore signing.verifyPayloadcalls it withparsed.alg, the algorithm the signature's own label names. So the label is checked against the verifying key's type instead of being trusted.parseSignatureadmits only theed25519label, so the same comparison is also the refusal of every non-Ed25519 key. It is one rule, not two.KeyObjectis used as given, anything else goes throughcreatePrivateKey/createPublicKey". It used to be "a string is parsed, anything else is passed through". This keeps every input node accepted working for an Ed25519 key, measured: PEM string,KeyObject, PEM buffer, DER object and JWK object, at sign and at verify. Without it, a PEM buffer would have reached the check with no key type.verifyPublisherSignature,verifyPlatformSignatureandverifyPluginArtifactverify throughverifyPayloadand get the check from there. Only their docblocks changed.Why
verifyPayloadthrows where it used to answerfalse. The card asks for this to be justified. The ruling requires the refusal to be loud and to name the key type, and a boolean can carry neither. The trust paths below show that the verifying key always comes from the caller's own configuration, never from the artifact. A non-Ed25519 key is therefore a misconfigured trust anchor, not a verdict on the signed bytes. Folded intofalse, it would read exactly like a tampered artifact. Every case that answeredfalsebefore still does: a malformed signature string, a key that cannot be parsed, and a signature that does not verify.os plugin sign: no edit topackages/cli/src/commands/plugin/sign.ts. Measured: its existingcatcharoundsignPayload(sign.ts:81-87atad7c351898) already prints✗ Signing failed: …and callsthis.exit(1)from thecatchbody, outside thetry. So the refusal surfaces as one error line and exit 1 with no source change. PR #21522, which edits that file, has since landed (5895119c35). Its change is in the self-verificationcatch, not on this path. The CLI pin was also run against main's landedsign.ts: 3/3 passed (see Tests).Changeset:
@objectstack/coreminor, BREAKING,Clause-②: no (narrowing), ADR-0087not-required (no-migration-prescription).check:adr-0087-registrationreads it and passes.Trust paths of
verifyPluginArtifactRead at
ad7c351898, inpackages/core/src/security/plugin-artifact-signature.tsunless named otherwise.keys.platformPublicKey(:212), used at:228-229, thenverifyPlatformSignature(:171), thenverifyPayload. This is caller configuration.keys.getPublisherPublicKey(keyId)(:213), passed at:219-222toverifyPublisherSignature(:148), and resolved at:162. The resolver is the caller's key registry. The artifact contributes only thekeyId, read out of its own signature string byparseSignature(:81). That id selects an entry in the caller's registry. An id the registry does not know is refused (:163), and no resolver meansverified: false(:158-160). The artifact cannot introduce a key.signature,platform_signature,package_id,versionandblob_key. These are signature strings and identity only. No field carrying a key is read.packages/core/src/plugin-loader.ts:512-536(verifyPluginSignature) readsplugin.signaturethroughparseSignaturefor well-formedness and logsalg/keyId. There is no key and no cryptographic check.verifyPluginArtifactin this repository: none.git grepfinds the module, the barrel re-export (security/index.ts:34) and the module's test. This matches ADR-0025's status line, which says there is no.ospluginloader and no runtime path on which a distributed plugin executes.os plugin sign's self-check (sign.ts:95-96) verifies with the public half derived from the private key it just signed with. That is a self-consistency check, not a trust path.Against triage's raise rule: no path admits a key supplied with the artifact. The one input the artifact chooses is the
keyId, a selector over the caller's registry that cannot reach a key outside it. This is a reading, stated for the seat to grade.Cloud counterpart
NOT MEASURED: no read access to objectstack-ai/cloud from this session. The module header claims byte-for-byte compatibility with the cloud control plane's signing module, and this PR did not read that module. Whether it has the same gap, and the bare finding to file there if it does, is left to a seat with that access. For an Ed25519 key this side's output is unchanged: the signature bytes are deterministic and identical before and after.
Tests (at
f1f4368020)pnpm --filter @objectstack/core exec vitest run --project local src/security/plugin-artifact-signature.test.ts: 24 passed, 14 existing and 10 new.signPayload, as PEM and asKeyObject.verifyPayloadwith a signature that verifies cryptographically, so the check is the only thing refusing it.verifyPluginArtifacttrust paths: the publisher registry and the platform key.KeyObjectsignature are byte-identical.ed25519label over an RSA key throws, and anrsalabel answersfalse.secret. An unreadable key and a malformed signature still answerfalse.pnpm --filter @objectstack/core test: 76 files, 2166 passed.pnpm --filter @objectstack/core typecheck: exit 0. The test file is in thetsconfig.test.jsonprogram (--listFiles: 1 hit).pnpm --filter @objectstack/cli exec vitest run --project unitovertest/plugin-sign.test.ts,test/plugin-commands.test.ts,test/plugin-publish.test.ts,test/plugin-publish-visibility.test.tsandtest/json-exit-signal.pin.test.ts: 5 files, 100 passed. The new pin, for an RSA key and an EC key, asserts four things: exit 1, exactly one error line, that line beingsignPayload's refusal naming the key type, and noPlugin signedand no sidecar.pnpm --filter @objectstack/cli typecheck: exit 0. The CLIintegrationtier is left to CI: the diff touches no integration-tier file and no spawn entry.sign.tswas swapped to main's landed copy (550f4cc2fd, which carries PR fix(cli): package install, package publish and plugin sign print one error line per refusal; the exit-signal pin covers every command #21522) and then restored. The blob matched HEAD andgit diff HEADwas empty. The CLI pin passed 3/3.@objectstack/corethroughdist/, socorewas rebuilt before every CLI reading.Ablation and reverse verification
Each leg ran on committed code, through
scripts/ablation-replace.mjs. Each restore was proven by blob hash equal to HEAD and an emptygit diff HEAD. Every leg below went red as predicted, except C's first run, which is described under C.signPayloadcheck. The core suite resolves source. 3 red: RSA sign, EC sign andsecret. 21 green.verifyPayloadcheck. 5 red: RSA and EC verify, RSA and EC trust paths, and the label case. 19 green.signPayloadcheck, measured at the CLI. Core was rebuilt, andablation-dist-preflightconfirmed the marker absent fromdist/. Observed direction, first run: the CLI pin stayed green. The verify-side check refused the same key at the self-verification step, so the command still exited 1 with one error namingrsa. The two checks overlap at this door. The pin was then tightened to assert that the refusal issignPayload's, given at sign time. Re-run: 2 red (expected '✗ Self-verification error: verifyPayload…' to match /signPayload: the private key is of type …/).ablation-d-noopmarker was present indist/. 2 red:expected undefined to be 1. The command completed with exit 0, which reproduces the card's measurement at the public door.Gates
node scripts/pm/dispatch-gates.mjs --commands, run without paths on the final diff: 64 commands, treef1f4368020, merge basead7c35189. All 64 exited 0.check:dual-build-cjs-loadsandcheck:lean-entry-closurefirst answered exit 3 PREREQUISITE NOT MET. Both were re-run green after a full turbo build (72 tasks, 71 cached).--ranreconciliation: 64 derived, 64 run, 0 NOT-MEASURED. That zero is derived, because every line carries its exit code.check:i18n,check:i18n-coverageandcheck:i18n-walk-parity. The derivation on this diff does not, because nopackages/cli/srcpath changed. They were not run.Lint, a declared narrowing:
pnpm exec eslint --no-inline-config --format jsonon the three touched TypeScript files gave 3 files, 0 errors and 0 warnings.files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']. The changeset is outside it.eslint.config.mjsenables no type-aware linting. It sets noparserOptions.projectand no typed rules, and everyparserOptionsblock isecmaVersionandsourceTypeonly. Its only disk reads are two baseline JSON files this diff does not touch. So the diff cannot move the verdict on an untouched file.Acceptance notes
PluginSignatureVerifier(packages/core/src/security/plugin-signature-verifier.ts) is a second plugin-signature verifier, exported from the same barrel. It declaresalgorithm: 'RS256' | 'ES256'and verifies withcreateVerify('RSA-SHA256')/createVerify('sha256'), where the key, not the declared algorithm, again decides the scheme. Measured with node directly, not through the class:createVerify('RSA-SHA256')verifies an ECDSA signature against an EC key astrue. It also expects a bare base64 signature, whileplugin-loader.tsrequiresPluginMetadata.signatureto parse ased25519:KEYID:SIG. It has zero production callers: the barrel and one pin test that asserts it is exported. The class was read, not run, and has no live reach, so it is noted and not filed. No carrier.verifyPayloadstill answersfalsefor a key it cannot parse at all, which is the trust configuration being wrong in a different way. It is left as it was, under the card's rule of no throw wherefalsewas answered before, and noted only. No carrier.Generated by Claude Code