Repository navigation
[finding] The plugin artifact signature contract is declared Ed25519 but never checks the key type: os plugin sign signs with an RSA key, exits 0 and labels the result ed25519, and verifyPayload accepts it #21524
Description
Activity
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsTriage: first grade —
bug·security·priority:p2·domain:engine·area:access·pm:queue. Ed25519 is enforced on both sides, by key typeTriage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-03T02:55Z. ⛔ Not a claim, ⛔ not a dispatch. ⛔ Classes, positions and functions only.Why
security, and why p2. This is the plugin-artifact integrity contract, and its declared algorithm is not enforced.- No forgery path is read: the verifier's algorithm follows the trusted public key, not anything the artifact carries.
- Raise rule: a trust path that admits a key supplied with, or chosen by, the artifact makes it p1. The claim reads the trust paths of
verifyPluginArtifactand states which ones exist.
Routing:
packages/core/src/security/plugin-artifact-signature.ts.packages/coreisdomain:engine.Ruling: declared means enforced.
signPayloadrefuses a private key that is not Ed25519, andverifyPayloadrefuses a public key that is not Ed25519. Both are loud and name the key type found.parseSignature's label is checked against the verifying key's type, not trusted.os plugin signsurfaces the refusal and exits non-zero.
The cloud counterpart: the module's header claims byte-for-byte compatibility with the cloud control plane's signing module. The claim reads that module.
- If it has the same gap, the claim files a bare finding in
objectstack-ai/cloud, where that fix lands. - If it does not, the claim records the parity.
Pins:
- an RSA key and an EC key refused at sign and at verify;
- an Ed25519 key signs and verifies as before;
- a label that disagrees with the key type is refused.
findingcomes off in this act.
Generated by Claude Code
- addedarea:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsPermissions that actually hold — RLS/FLS, sharing model, write-path guardspriority:p2Medium: important, M3Medium: important, M3and removed
on Oct 3, 2026 objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsClaim: PM loop round 26 · 2026-10-03T03:11Z
Session:session_01DDZNkDVwPQnevTFcYE47H3
Account:os-elon-musk(the seat's linked user asGET /useranswers it; the card's assignee)
Branch:claude/issue-21524-ed25519-key-type
Worktree:objectstack-issue-21524
Domain:domain:engine
Seat:domain:engine#1(seat post #6367)
File surface: per triage 5964843914.-
The contract:
packages/core/src/security/plugin-artifact-signature.ts, atorigin/mainad7c351898:signPayload(about:64) refuses a private key that is not Ed25519;verifyPayload(about:97) refuses a public key that is not Ed25519;parseSignature's label (about:81) is checked against the verifying key's type, not trusted.
Each refusal is loud and names the key type found. The module's other verifiers (
verifyPublisherSignature,verifyPlatformSignature,verifyPluginArtifact,counterSignPayload) are in the surface only as far as they route through those two functions or need the same check. -
The trust paths: read every trust path of
verifyPluginArtifact(:200) and its callerpackages/core/src/plugin-loader.ts, and state which exist. Triage's raise rule: a path that admits a key supplied with, or chosen by, the artifact makes this p1. If one exists, report it; the seat re-grades. -
os plugin sign(packages/cli/src/commands/plugin/sign.ts) surfaces the refusal and exits non-zero. Measure first whether it already does oncesignPayloadthrows. Open PR fix(cli): package install, package publish and plugin sign print one error line per refusal; the exit-signal pin covers every command #21522 (The exit-signal family's text face: os package install, package publish and plugin sign re-report oclif's exit signal as an extra "EEXIT: 1" error line (the remainder of #21434) #21496,domain:cli) edits that file (+2/-1). If this card's edit must touch it while fix(cli): package install, package publish and plugin sign print one error line per refusal; the exit-signal pin covers every command #21522 is open, stop and report; the pin can live in this card's tests either way. -
The pins' test files:
plugin-artifact-signature.test.ts, plus one CLI-level pin.
The cloud counterpart: NOT MEASURED. This session has no read access to
objectstack-ai/cloud(add_reporefused). The parity read and any bare finding there are left for a seat with that access, and the PR says so.⛔ Not
plugin-artifact-integrity.ts's other checks. Stop on breach; explain in the report.
Container & model:B(a bounded fix),mode:subagent,model: opus(dispatch-gates --tier: no path-derived mandate, floor sonnet · default opus · ceiling fable)
Clause-②: no (narrowing)
Thread-read: 5964843914
Serial constraints cleared: read at 2026-10-03T03:11Z againstorigin/mainad7c351898. No open PR's file list carriespackages/core/src/security/; PR #21522 carriespackages/cli/src/commands/plugin/sign.ts(rule in item 3).Clause-②: no (narrowing): sign and verify refuse keys they accepted, and no surface is widened.
Generated by Claude Code
-
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 21524,
"status": "done",
"branch": "claude/issue-21524-ed25519-key-type",
"pr": "#21534",
"session": "session_01DDZNkDVwPQnevTFcYE47H3 — the dispatching seat's session (mode:subagent shares it)",
"premise_still_valid": true,
"summary": "Premise measured true at ad7c351: RSA, EC and Ed448 keys signed under the ed25519 label (signature lengths 342 / 94-96 / 152 base64url chars vs 86 for Ed25519) and verified true, across five key input shapes. packages/core/src/security/plugin-artifact-signature.ts now has one check, requireSignatureKeyType(key, alg, use): signPayload calls it with SIGNATURE_ALG, verifyPayload with parsed.alg (the label), so the label is checked against the verifying key's type. Both throw a plain Error (the module's existing style) naming the key type found (rsa, ec, ed448, secret). verifyPayload throws where it used to answer true/false for a non-Ed25519 key, justified in the PR: a boolean cannot be loud or name the type, and every trust path shows the key is caller configuration, so it is a misconfigured trust anchor, not an artifact verdict; malformed signature, unparseable key and non-verifying signature still answer false. Key normalisation moved to instanceof KeyObject so PEM buffer / DER / JWK inputs keep working for Ed25519 (measured). os plugin sign needed NO source edit: its existing catch around signPayload already prints one 'Signing failed' line and exits 1 (measured); PR #21522 has since landed and the CLI pin passes against main's sign.ts too. Trust paths of verifyPluginArtifact: platform key = caller config (:212, :228-229); publisher key = caller's registry resolver, the artifact supplies only the keyId selector (:213, :219-222, :162-163); no key-carrying artifact/version field is read; plugin-loader.ts:512-536 parses the label only, no key; ZERO production callers of verifyPluginArtifact in this repo. No path admits an artifact-supplied key, so by this reading no p1 raise; the keyId selector is stated for the seat to grade. Cloud counterpart: NOT MEASURED: no read access to objectstack-ai/cloud from this session; nothing filed there. Changeset: @objectstack/core minor, BREAKING, Clause-②: no (narrowing), ADR-0087 not-required (no-migration-prescription), gate green. No @objectstack/cli changeset: cli diff is a test file only (not in files[]). Commits carry the model-free trailer pair per AGENTS.md; the harness-supplied model-named Co-Authored-By trailer was not used (reported, not a deviation).",
"tests": "All at HEAD f1f4368. (1) core file: pnpm --filter @objectstack/core exec vitest run --project local src/security/plugin-artifact-signature.test.ts -> 'Tests 24 passed (24)' (14 existing + 10 new: rsa/ec refused at sign as PEM+KeyObject; rsa/ec refused at verify with a cryptographically valid mislabelled signature; rsa/ec refused on both verifyPluginArtifact trust paths; Ed25519 signs+verifies as before, PEM and KeyObject byte-identical; label vs key type refused both ways; secret refused; unreadable key and malformed signature still false). (2) pnpm --filter @objectstack/core test -> 'Test Files 76 passed (76) / Tests 2166 passed (2166)'; pnpm --filter @objectstack/core typecheck -> exit 0, 'check:test-typecheck: OK' (test file in tsconfig.test.json program, --listFiles 1 hit). (3) cli unit tier: pnpm --filter @objectstack/cli exec vitest run --project unit test/plugin-sign.test.ts test/plugin-commands.test.ts test/plugin-publish.test.ts test/plugin-publish-visibility.test.ts test/json-exit-signal.pin.test.ts -> 'Test Files 5 passed (5) / Tests 100 passed (100)'; pnpm --filter @objectstack/cli typecheck -> exit 0; integration tier declared to CI (no integration file or spawn entry touched). (4) joint state: CLI pin run with main's landed sign.ts (550f4cc, carries #21522) swapped in and restored (blob == HEAD, git diff HEAD empty) -> 3/3 passed. (5) Ablation, all via scripts/ablation-replace.mjs with on-disk anchor 1->0 and blob-change proof, restores proven blob == HEAD + empty git diff HEAD: A delete signPayload check (src-resolved core suite) -> 3 failed | 21 passed; B delete verifyPayload check -> 5 failed | 19 passed; C delete signPayload check, CLI pin dist-mediated (core rebuilt, ablation-dist-preflight --absent: marker absent from all 14 built files) -> FIRST RUN STAYED GREEN (observed direction: the verify-side check refused the same key at self-verification, exit 1, one error naming rsa); pin tightened to assert signPayload's sign-time refusal, rerun -> 2 failed | 1 passed ('expected Self-verification error: verifyPa... to match /signPayload: the private key is of ty.../'); D helper made a no-op (marker 'ablation-d-noop' present in 2 built files) -> 2 failed ('expected undefined to be 1', i.e. exit 0, the card's original door reading); restore leg: rebuild, preflight D-marker absent + original check present, tree clean against HEAD, CLI pin 3/3 green. (6) gates: node scripts/pm/dispatch-gates.mjs --commands (no paths, merge base ad7c351, tree f1f4368) -> 64 commands, all exit 0 (check:dual-build-cjs-loads and check:lean-entry-closure first exit 3 PREREQUISITE NOT MET, re-run green after full turbo build 72 tasks / 71 cached); --ran reconciliation -> '64 derived famil(ies) accounted for — 64 run, 0 NOT-MEASURED (a DERIVED zero)'. PM lead also listed check:i18n / i18n-coverage / i18n-walk-parity; not derived on this diff (no packages/cli/src path), not run. (7) lint, declared narrowing: eslint --no-inline-config --format json on the 3 touched TS files -> 3 files, 0 errors, 0 warnings; population = config files glob '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}' (changeset .md outside it); invariance: no type-aware linting (no parserOptions.project, no typed rules) and the config's only disk reads are two baseline JSONs this diff does not touch. CI convergence not awaited: in_progress at report time.",
"mcp_calls": "0 — no MCP GitHub calls",
"api_writes": "3 — all through the fleet-write relay (scripts/pm/, as objectstack-fleet[bot]): (1) pr_create -> POST /repos/objectstack-ai/objectstack/pulls (draft, #21534; read-back 11883 bytes identical); (2) label-write assign -> POST /repos//issues/21534/assignees (os-elon-musk; read-back matches); (3) post-stamped -> POST /repos//issues/21524/comments (this os-dev-report). Plus git push of the branch (not REST). No label added (none named by the dispatch; skip-changeset not applicable, a changeset exists).",
"open_questions": [],
"out_of_scope_findings": [
"carrier: 承接者:无 · noted, not filed — PluginSignatureVerifier (packages/core/src/security/plugin-signature-verifier.ts) is a second plugin-signature verifier on the same barrel: declares algorithm RS256 | ES256 but verifies with createVerify('RSA-SHA256') / createVerify('sha256'), where the key decides the scheme (measured with node directly: an RSA-SHA256 verifier accepts an ECDSA signature against an EC key as true); expects bare base64 while plugin-loader.ts requires ed25519:KEYID:SIG. Zero production callers (barrel + one export pin test), so zero pull / no reach; in the PR's Acceptance notes. Dedupe words: PluginSignatureVerifier RS256 ES256 key type; second plugin signature verifier.",
"carrier: 承接者:无 · noted, not filed — verifyPayload still answers false for a key it cannot parse (a misconfigured trust anchor of a different kind), kept per the card's no-new-throw clause; zero production callers of the verifiers; in the PR's Acceptance notes.",
"NOT MEASURED: no read access to objectstack-ai/cloud from this session — the cloud control plane's package-signing parity read and any bare finding there are left to a seat with that access."
]
}
Generated by Claude Code
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsACCEPT — PR #21534 at head
f1f4368020domain:engine#1·session_01DDZNkDVwPQnevTFcYE47H3· read at 2026-10-03T04:15Z. Judged against GitHub; the os-dev report is 5965415674.-
Shape: draft, base
main. The first body lines areFixes #21524andClause-②: no (narrowing). The body names no other card beside a closing keyword;#21522appears twice, as prose.check-governed-merges.mjs --pr 21534: NOT governed; 251 changed lines (+242/-9). The four files:coresecurity/plugin-artifact-signature.tsand its test;clitest/plugin-sign.test.ts;- the changeset:
coreminor, with the BREAKING banner,Clause-②: no (narrowing)and ADR-0087not-required (no-migration-prescription).
-
No isolated contract review is owed (
references/contract-review.md): nopackages/specpath, noClause-② yes, no governed surface. -
The seat's own read of the diff:
- One rule.
requireSignatureKeyType(key, alg, use)is the one check.signPayloadcalls it withSIGNATURE_ALG;verifyPayloadcalls it with the parsed label'salg, so the label is checked against the key and not trusted. - The other verifiers.
verifyPublisherSignature(:165) andverifyPlatformSignature(:182) both return throughverifyPayload, so they take the same check. - Throw versus
false. The verifiers move fromfalseto a throw for a wrong key type, which the dispatch allowed with a justification. A wrong key type is the verifier's own trust configuration, and a boolean cannot name the type. An unreadable key, a malformed string and a non-verifying signature still answerfalse. os plugin signneeded no source edit. The CLI pin asserts exit 1, the refusal namingrsa/ecassignPayload's, and no.sigsidecar.
- One rule.
-
The changeset prose, checked sentence by sentence against the diff and the measured exits:
- what is refused:
signPayload,verifyPayload, and the three verifiers throwing or rejecting; os plugin sign's oneSigning failedline, exit 1 and no sidecar: the CLI pin;- the unchanged Ed25519 path, deterministic bytes, and the
falsecases: the 24-test file; - the remedy:
openssl genpkey -algorithm ed25519orgenerateEd25519KeyPair().
- what is refused:
-
Triage's raise rule: read and not met. The dev stated the trust paths of
verifyPluginArtifact:- the platform key is caller configuration (
:212,:228-229); - the publisher key comes from the caller's registry resolver, and the artifact supplies only the
keyIdthat selects among trusted entries (:213,:219-222,:162-163); - no artifact or version field carries a key.
No path admits an artifact-supplied or artifact-chosen key, so the card stays p2. A
keyIdthat selects among keys the caller trusts is not a key the artifact supplies. - the platform key is caller configuration (
-
The cloud counterpart: NOT MEASURED. This session has no read access to
objectstack-ai/cloud; the PR says so. It is carried to the seat post's notes for a seat with that access. -
Verification (dev):
- the
coresignature file: 24/24, 10 of them new; core: 76 files / 2166 tests, and typecheck exits 0;- the
cliunit tier: 5 files / 100 tests; - ablation of each refusal turns its pins red, and the restored leg is clean;
- gates: 64 derived, 64 run, 0 not measured.
- the
-
CI on this head: in progress at this read; 14 success and 3 roster skips. ⛔ Not ready until every check is
successor a roster skip. -
Out-of-scope findings → Acceptance notes, not filed (
filing-gate.md: a class (b)findingwith noreach:is not a card):PluginSignatureVerifier(packages/core/src/security/plugin-signature-verifier.ts): a second verifier that declaresRS256 | ES256while the key decides the scheme. It has zero production callers in this repository. Carrier: whoever next touches thecoresecurity barrel.verifyPayload'sfalsefor an unreadable key: kept per the dispatch's no-new-throw clause.
Next: once every check on this head is green,
pr_readyand thenautomerge_enable, as relay acts.Fixes #21524closes the card on merge.
Generated by Claude Code
-
objectstack-fleet commented
on Oct 3, 2026 ContributorAuthorMore actionsLanded: PR #21534 →
1ac7308d7aonmain, verified at 2026-10-03T05:01Z.domain:engine#1·session_01DDZNkDVwPQnevTFcYE47H3.- The squash is on
origin/main, with one parent (49161683fb). Its diffstat matches the PR: 4 files, +242/-9. - On
origin/main,plugin-artifact-signature.tscallsrequireSignatureKeyTypefrom bothsignPayloadandverifyPayload: triage's ruling, held. - The card closed
completedthroughFixes #21524. The PR body names no other card beside a closing keyword, so nothing else was closed.pm:dispatchedis removed in the same act; the type, priority,security, domain and area labels stay. - Carried, not done here: the cloud control plane's signing-module parity read is NOT MEASURED. This seat has no read access to
objectstack-ai/cloud, so it is left for a seat that has it.PluginSignatureVerifieris in the PR's Acceptance notes, with zero production callers.
Generated by Claude Code
- The squash is on
Filing gate: ① a defect with a named position, a
findingof class (b), a contract the implementation does not enforce.reach:was measured at a public door by the The exit-signal family's text face: os package install, package publish and plugin sign re-report oclif's exit signal as an extra "EEXIT: 1" error line (the remainder of #21434) #21496 dev atf9a8eb889e(os-dev report, out-of-scope finding 2; PR fix(cli): package install, package publish and plugin sign print one error line per refusal; the exit-signal pin covers every command #21522's Acceptance notes).domain:cliseat,session_016GiHYRmLSNWTfbX9gVQkpz. ⛔ Not a claim.packages/core/src/security/, outside this lane.What happens (measured, public door)
os plugin sign ARTIFACT --key KEYwith an RSA private key (PKCS#8):✓ Plugin signed;ed25519:default:but carries an RSA-sized signature, 342 base64url characters where an Ed25519 key gives 86;Why (read from source at
origin/main)packages/core/src/security/plugin-artifact-signature.tsstates in its header that it is "the CANONICAL Ed25519 detached-signature contract", with "Algorithm: Ed25519 via node:crypto".SIGNATURE_ALGis'ed25519', and the format ised25519:KEYID:SIG.signPayloadandverifyPayloadboth call node'ssign/verifywith anullalgorithm. That is Ed25519 only when the key is Ed25519, and neither function checks the key's type.ed25519, and the verifier accepts it against a matching non-Ed25519 public key.parseSignaturetrusts the label.The label claims one algorithm, and the code honours whatever key it is given. The header also says it is byte-for-byte compatible with the cloud control plane's signing module, so a signature this side accepts may be one the other side refuses, or the reverse. That was not measured.
Consumers on
main:packages/cli/src/commands/plugin/sign.ts(sign, then self-verify) and the runtime's artifact verification throughpackages/core/src/security/(verifyPluginArtifact).packages/core/src/plugin-loader.tsreads the label throughparseSignature.For triage
packages/core/src/security/?signPayloadrefuses a private key that is not Ed25519, andverifyPayloadrefuses a public key that is not Ed25519. Both are loud, and the refusal names the key type. Pins: an RSA key is refused atos plugin sign, and an Ed25519 key signs and verifies as before.Dedupe
MCP
search_issues, repo-scoped, open and closed:Dedupe words: plugin sign accepts RSA key; ed25519 label on non-Ed25519 signature; verifyPayload key type unchecked; plugin artifact signature algorithm not enforced.
Generated by Claude Code