Skip to content

fix(core)!: the plugin artifact signature contract refuses any key that is not Ed25519 - #21534

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21524-ed25519-key-type
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21524-ed25519-key-type

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21524

Clause-②: no (narrowing)

The plugin artifact signature contract in @objectstack/core declares Ed25519 and now enforces it. Before this change, signPayload and verifyPayload handed any key to node's sign(null, …) / verify(null, …), and those follow the key. So an RSA, EC or Ed448 key signed under the ed25519:KEYID:SIG label and verified against its own public half. Measured at ad7c351898, 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 labelled ed25519 and every one verified true.

What changed

packages/core/src/security/plugin-artifact-signature.ts:

  • One check, requireSignatureKeyType(key, alg, use). A key passes only when key.asymmetricKeyType IS the algorithm. Otherwise it throws a plain Error, the module's existing style, naming the key type found (rsa, ec, ed448, or secret for a symmetric key).
  • signPayload calls the check with SIGNATURE_ALG before signing.
  • verifyPayload calls it with parsed.alg, the algorithm the signature's own label names. So the label is checked against the verifying key's type instead of being trusted. parseSignature admits only the ed25519 label, so the same comparison is also the refusal of every non-Ed25519 key. It is one rule, not two.
  • Key normalisation is now "a KeyObject is used as given, anything else goes through createPrivateKey / 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, verifyPlatformSignature and verifyPluginArtifact verify through verifyPayload and get the check from there. Only their docblocks changed.

Why verifyPayload throws where it used to answer false. 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 into false, it would read exactly like a tampered artifact. Every case that answered false before still does: a malformed signature string, a key that cannot be parsed, and a signature that does not verify.

os plugin sign: no edit to packages/cli/src/commands/plugin/sign.ts. Measured: its existing catch around signPayload (sign.ts:81-87 at ad7c351898) already prints ✗ Signing failed: … and calls this.exit(1) from the catch body, outside the try. 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-verification catch, not on this path. The CLI pin was also run against main's landed sign.ts: 3/3 passed (see Tests).

Changeset: @objectstack/core minor, BREAKING, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription). check:adr-0087-registration reads it and passes.

Trust paths of verifyPluginArtifact

Read at ad7c351898, in packages/core/src/security/plugin-artifact-signature.ts unless named otherwise.

  1. Platform key: keys.platformPublicKey (:212), used at :228-229, then verifyPlatformSignature (:171), then verifyPayload. This is caller configuration.
  2. Publisher key: keys.getPublisherPublicKey(keyId) (:213), passed at :219-222 to verifyPublisherSignature (:148), and resolved at :162. The resolver is the caller's key registry. The artifact contributes only the keyId, read out of its own signature string by parseSignature (:81). That id selects an entry in the caller's registry. An id the registry does not know is refused (:163), and no resolver means verified: false (:158-160). The artifact cannot introduce a key.
  3. Fields read from the artifact or its version record: signature, platform_signature, package_id, version and blob_key. These are signature strings and identity only. No field carrying a key is read.
  4. packages/core/src/plugin-loader.ts:512-536 (verifyPluginSignature) reads plugin.signature through parseSignature for well-formedness and logs alg / keyId. There is no key and no cryptographic check.
  5. Production callers of verifyPluginArtifact in this repository: none. git grep finds 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 .osplugin loader and no runtime path on which a distributed plugin executes.
  6. 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.
    • An RSA key and an EC key are each refused at signPayload, as PEM and as KeyObject.
    • Each is refused at verifyPayload with a signature that verifies cryptographically, so the check is the only thing refusing it.
    • Each is refused on both verifyPluginArtifact trust paths: the publisher registry and the platform key.
    • An Ed25519 key signs and verifies as before. A PEM signature and a KeyObject signature are byte-identical.
    • A label that disagrees with the key type is refused both ways: an ed25519 label over an RSA key throws, and an rsa label answers false.
    • A symmetric key is refused as secret. An unreadable key and a malformed signature still answer false.
  • pnpm --filter @objectstack/core test: 76 files, 2166 passed. pnpm --filter @objectstack/core typecheck: exit 0. The test file is in the tsconfig.test.json program (--listFiles: 1 hit).
  • pnpm --filter @objectstack/cli exec vitest run --project unit over test/plugin-sign.test.ts, test/plugin-commands.test.ts, test/plugin-publish.test.ts, test/plugin-publish-visibility.test.ts and test/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 being signPayload's refusal naming the key type, and no Plugin signed and no sidecar.
  • pnpm --filter @objectstack/cli typecheck: exit 0. The CLI integration tier is left to CI: the diff touches no integration-tier file and no spawn entry.
  • Joint state: sign.ts was 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 and git diff HEAD was empty. The CLI pin passed 3/3.
  • The CLI suite resolves @objectstack/core through dist/, so core was 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 empty git diff HEAD. Every leg below went red as predicted, except C's first run, which is described under C.

  • A: delete the signPayload check. The core suite resolves source. 3 red: RSA sign, EC sign and secret. 21 green.
  • B: delete the verifyPayload check. 5 red: RSA and EC verify, RSA and EC trust paths, and the label case. 19 green.
  • C: delete the signPayload check, measured at the CLI. Core was rebuilt, and ablation-dist-preflight confirmed the marker absent from dist/. 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 naming rsa. The two checks overlap at this door. The pin was then tightened to assert that the refusal is signPayload's, given at sign time. Re-run: 2 red (expected '✗ Self-verification error: verifyPayload…' to match /signPayload: the private key is of type …/).
  • D: make the one check a no-op, measured at the CLI. The ablation-d-noop marker was present in dist/. 2 red: expected undefined to be 1. The command completed with exit 0, which reproduces the card's measurement at the public door.
  • Restore leg: core rebuilt. Preflight found the D marker absent and the original check present, and the tree clean against HEAD. The CLI pin passed 3/3.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands, run without paths on the final diff: 64 commands, tree f1f4368020, merge base ad7c35189. All 64 exited 0.
    • check:dual-build-cjs-loads and check:lean-entry-closure first answered exit 3 PREREQUISITE NOT MET. Both were re-run green after a full turbo build (72 tasks, 71 cached).
  • --ran reconciliation: 64 derived, 64 run, 0 NOT-MEASURED. That zero is derived, because every line carries its exit code.
  • The PM's lead list also named check:i18n, check:i18n-coverage and check:i18n-walk-parity. The derivation on this diff does not, because no packages/cli/src path changed. They were not run.

Lint, a declared narrowing: pnpm exec eslint --no-inline-config --format json on the three touched TypeScript files gave 3 files, 0 errors and 0 warnings.

  • Population: the config's files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']. The changeset is outside it.
  • Invariance: eslint.config.mjs enables no type-aware linting. It sets no parserOptions.project and no typed rules, and every parserOptions block is ecmaVersion and sourceType only. 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 declares algorithm: 'RS256' | 'ES256' and verifies with createVerify('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 as true. It also expects a bare base64 signature, while plugin-loader.ts requires PluginMetadata.signature to parse as ed25519: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.
  • verifyPayload still answers false for 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 where false was answered before, and noted only. No carrier.

Generated by Claude Code

claude added 4 commits October 3, 2026 03:18
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>
@github-actions github-actions Bot added the size/m label Oct 3, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

8 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
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 27 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 550f4cc2fd594f5965ef9b12da38538064ea9c9b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from cf1a7671946f5635f2a44588f6c365f21376e970 — the merge of head f1f43680202c8d822145ce8d71b02a384b011022 into base 550f4cc2fd594f5965ef9b12da38538064ea9c9b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 04:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 04:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit 1ac7308 Oct 3, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21524-ed25519-key-type branch October 3, 2026 05:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants