Skip to content

Commit 1ac7308

Browse files
fix(core)!: the plugin artifact signature contract refuses any key that is not Ed25519 (#21534)
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 #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](https://claude.ai/code/session_01DDZNkDVwPQnevTFcYE47H3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4916168 commit 1ac7308

4 files changed

Lines changed: 242 additions & 9 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
fix(core)!: the plugin artifact signature contract refuses any key that is not Ed25519, so its `ed25519` label now holds (#21524)
6+
7+
**BREAKING**: `signPayload` and `verifyPayload` (the plugin artifact signature contract in `@objectstack/core`) now refuse a key whose type is not Ed25519. Until now they accepted any asymmetric key. node's `sign(null, …)` and `verify(null, …)` follow the key they are handed, so an RSA, EC or Ed448 key signed under the `ed25519:KEYID:SIG` label and verified against its own public half. `os plugin sign --key` with an RSA private key exited 0, printed `Plugin signed`, and wrote an `ed25519:`-labelled sidecar over an RSA signature.
8+
9+
What is refused now:
10+
11+
- **`signPayload`** throws when the private key is not Ed25519. The error names the key type found (`rsa`, `ec`, `ed448`, and `secret` for a symmetric key).
12+
- **`verifyPayload`** throws when the verifying key's type is not the algorithm the signature's label names. The label is checked against the key, not trusted, and the only label the contract parses is `ed25519`. The error names the key type found.
13+
- **`verifyPublisherSignature`, `verifyPlatformSignature` and `verifyPluginArtifact`** verify through `verifyPayload`. So a publisher key registry entry or a platform key that is not Ed25519 makes them throw, or reject, with that same error. It is not folded into a `false` or an `ok: false` result, because a wrong key is the verifier's own configuration, not a verdict on the artifact.
14+
- **`os plugin sign`** prints one `✗ Signing failed: signPayload: …` line naming the key type, exits 1, and writes no sidecar.
15+
16+
Each refusal is a plain `Error`, the error style the module already used.
17+
18+
**The fix:** sign with an Ed25519 key, generated with `openssl genpkey -algorithm ed25519` or `generateEd25519KeyPair()`. Configure Ed25519 public keys for the publisher key registry and the platform key. A signature made earlier with a non-Ed25519 key cannot be verified any more. Sign the artifact again with an Ed25519 key.
19+
20+
**Unchanged:** an Ed25519 key signs and verifies exactly as before, with the same deterministic signature bytes. That holds for a PEM string, a `KeyObject`, and the PEM buffer, DER and JWK inputs node also accepts. A malformed signature string, a signature that does not verify, and a key that cannot be read still answer `false`. The signature string format and every export are unchanged.
21+
22+
Clause-②: no (narrowing)
23+
24+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of non-Ed25519 signing and verifying keys by the plugin artifact signature functions in @objectstack/core. No authorable key, spelling, export or stored metadata shape moves: the change is which cryptographic keys signPayload and verifyPayload accept, and a key is an operational secret that no ledger entry or os migrate meta run can rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers the signature contract (not already-registered); and the changed exports are functions whose behaviour narrows, not an interface or type declaration (not runtime-interface-only or type-surface-only). -->

‎packages/cli/test/plugin-sign.test.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, afterAll } from 'vitest';
3+
import { describe, it, expect, afterAll, afterEach, vi } from 'vitest';
4+
import { generateKeyPairSync } from 'node:crypto';
5+
import { existsSync } from 'node:fs';
46
import { gunzipSync } from 'node:zlib';
57
import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises';
68
import { tmpdir } from 'node:os';
@@ -59,3 +61,49 @@ describe('os plugin sign (end-to-end, build → sign → verify)', () => {
5961
expect(() => gunzipSync(Buffer.from(artifactBytes))).not.toThrow();
6062
});
6163
});
64+
65+
describe('os plugin sign refuses a publisher key that is not Ed25519', () => {
66+
// Without the key-type check an RSA key signed, printed `Plugin signed`,
67+
// exited 0 and wrote an `ed25519:`-labelled sidecar over an RSA signature.
68+
const dirs: string[] = [];
69+
afterEach(() => vi.restoreAllMocks());
70+
afterAll(async () => {
71+
for (const d of dirs) await rm(d, { recursive: true, force: true });
72+
});
73+
74+
const foreign = [
75+
{ type: 'rsa', pair: () => generateKeyPairSync('rsa', { modulusLength: 2048 }) },
76+
{ type: 'ec', pair: () => generateKeyPairSync('ec', { namedCurve: 'P-256' }) },
77+
] as const;
78+
79+
for (const { type, pair } of foreign) {
80+
it(`refuses an ${type} private key: exit 1, the refusal names '${type}', no sidecar`, async () => {
81+
const dir = await mkdtemp(join(tmpdir(), 'osplugin-sign-keytype-'));
82+
dirs.push(dir);
83+
const artifactPath = join(dir, 'com.acme.keytype-1.0.0.osplugin');
84+
await writeFile(artifactPath, Buffer.from('artifact bytes'));
85+
const keyPath = join(dir, `${type}.key.pem`);
86+
await writeFile(keyPath, pair().privateKey.export({ type: 'pkcs8', format: 'pem' }).toString());
87+
88+
const printed: string[] = [];
89+
vi.spyOn(console, 'log').mockImplementation((...a: unknown[]) => void printed.push(a.join(' ')));
90+
91+
let exit: number | undefined;
92+
try {
93+
await PluginSign.run([artifactPath, '--key', keyPath]);
94+
} catch (err) {
95+
exit = (err as { oclif?: { exit?: number } }).oclif?.exit;
96+
if (typeof exit !== 'number') throw err;
97+
}
98+
99+
expect(exit).toBe(1);
100+
const errors = printed.filter((line) => line.includes('✗'));
101+
expect(errors).toHaveLength(1);
102+
// The refusal is signPayload's: the key is refused before anything is
103+
// signed, not caught afterwards by the self-verification step.
104+
expect(errors[0]).toMatch(new RegExp(`signPayload: the private key is of type '${type}'`));
105+
expect(printed.join('\n')).not.toContain('Plugin signed');
106+
expect(existsSync(`${artifactPath}.sig`)).toBe(false);
107+
});
108+
}
109+
});

‎packages/core/src/security/plugin-artifact-signature.test.ts‎

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { createPublicKey, createPrivateKey } from 'node:crypto';
4+
import {
5+
createPublicKey,
6+
createPrivateKey,
7+
createSecretKey,
8+
generateKeyPairSync,
9+
sign as cryptoSign,
10+
verify as cryptoVerify,
11+
type KeyObject,
12+
} from 'node:crypto';
513
import {
614
counterSignPayload,
715
generateEd25519KeyPair,
@@ -145,3 +153,92 @@ describe('plugin-artifact-signature: KeyObject inputs', () => {
145153
expect(verifyPayload(artifact, sig, pub)).toBe(true);
146154
});
147155
});
156+
157+
describe('plugin-artifact-signature: the Ed25519 algorithm is enforced by key type', () => {
158+
// node's sign(null, …) / verify(null, …) follow whatever key they are
159+
// handed, so without a key-type check an RSA or EC key signs under the
160+
// `ed25519` label and verifies against its own public half.
161+
const foreign = [
162+
{ type: 'rsa', pair: generateKeyPairSync('rsa', { modulusLength: 2048 }) },
163+
{ type: 'ec', pair: generateKeyPairSync('ec', { namedCurve: 'P-256' }) },
164+
] as const;
165+
166+
/** A signature the unchecked contract used to emit: a foreign algorithm under the ed25519 label. */
167+
function mislabelled(priv: KeyObject): string {
168+
return `ed25519:k:${cryptoSign(null, artifact, priv).toString('base64url')}`;
169+
}
170+
171+
for (const { type, pair } of foreign) {
172+
const privPem = pair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
173+
const pubPem = pair.publicKey.export({ type: 'spki', format: 'pem' }).toString();
174+
const named = new RegExp(`of type '${type}'`);
175+
176+
it(`signPayload refuses an ${type} private key, naming its type (PEM and KeyObject)`, () => {
177+
expect(() => signPayload(artifact, privPem, 'k')).toThrow(named);
178+
expect(() => signPayload(artifact, pair.privateKey, 'k')).toThrow(named);
179+
});
180+
181+
it(`verifyPayload refuses an ${type} public key, naming its type (PEM and KeyObject)`, () => {
182+
const sig = mislabelled(pair.privateKey);
183+
// The signature is cryptographically valid for this key: only the key-type
184+
// check stands between it and `true`.
185+
expect(cryptoVerify(null, artifact, pair.publicKey, parseSignature(sig)!.signature)).toBe(true);
186+
expect(() => verifyPayload(artifact, sig, pubPem)).toThrow(named);
187+
expect(() => verifyPayload(artifact, sig, pair.publicKey)).toThrow(named);
188+
});
189+
190+
it(`every trust path of verifyPluginArtifact refuses an ${type} key`, async () => {
191+
const sig = mislabelled(pair.privateKey);
192+
const v = { package_id: 'com.acme.p', version: '1.0.0', signature: sig };
193+
// Publisher key registry hands back an rsa/ec key.
194+
await expect(
195+
verifyPluginArtifact(
196+
{ artifact, version: v },
197+
{ getPublisherPublicKey: () => pubPem, requirePlatform: false },
198+
),
199+
).rejects.toThrow(named);
200+
// Platform key is an rsa/ec key.
201+
const pubSig = signPayload(artifact, privateKeyPem, 'pub');
202+
const counter = `ed25519:platform:${cryptoSign(null, Buffer.from(counterSignPayload({ ...v, signature: pubSig })), pair.privateKey).toString('base64url')}`;
203+
await expect(
204+
verifyPluginArtifact(
205+
{ artifact, version: { ...v, signature: pubSig, platform_signature: counter } },
206+
{ platformPublicKey: pubPem, getPublisherPublicKey: () => publicKeyPem },
207+
),
208+
).rejects.toThrow(named);
209+
});
210+
}
211+
212+
it('an Ed25519 key signs and verifies as before, as PEM and as KeyObject', () => {
213+
const pemSig = signPayload(artifact, privateKeyPem, 'k');
214+
const objSig = signPayload(artifact, createPrivateKey(privateKeyPem), 'k');
215+
expect(objSig).toBe(pemSig);
216+
expect(verifyPayload(artifact, pemSig, publicKeyPem)).toBe(true);
217+
expect(verifyPayload(artifact, pemSig, createPublicKey(publicKeyPem))).toBe(true);
218+
// A private key still verifies through its public half, as node allows.
219+
expect(verifyPayload(artifact, pemSig, privateKeyPem)).toBe(true);
220+
});
221+
222+
it('a label that disagrees with the key type is refused, in both directions', () => {
223+
const [{ pair: rsa }] = foreign;
224+
const rsaPub = rsa.publicKey.export({ type: 'spki', format: 'pem' }).toString();
225+
const rsaSig = cryptoSign(null, artifact, rsa.privateKey).toString('base64url');
226+
// `ed25519` label over an rsa key: the label is checked against the key, not trusted.
227+
expect(() => verifyPayload(artifact, `ed25519:k:${rsaSig}`, rsaPub)).toThrow(/of type 'rsa'/);
228+
// A truthful `rsa` label is not a label this contract accepts at all.
229+
expect(verifyPayload(artifact, `rsa:k:${rsaSig}`, rsaPub)).toBe(false);
230+
// A valid Ed25519 signature relabelled as anything else does not verify.
231+
const edSig = signPayload(artifact, privateKeyPem, 'k').slice('ed25519:'.length);
232+
expect(verifyPayload(artifact, `rsa:${edSig}`, publicKeyPem)).toBe(false);
233+
});
234+
235+
it('a symmetric key is refused as `secret`', () => {
236+
expect(() => signPayload(artifact, createSecretKey(Buffer.alloc(32)), 'k')).toThrow(/of type 'secret'/);
237+
});
238+
239+
it('an unreadable key or a malformed signature still answers false, not a throw', () => {
240+
const sig = signPayload(artifact, privateKeyPem, 'k');
241+
expect(verifyPayload(artifact, sig, 'not a pem')).toBe(false);
242+
expect(verifyPayload(artifact, 'garbage', publicKeyPem)).toBe(false);
243+
});
244+
});

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

Lines changed: 71 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@
2020
* short, deterministic, no padding ambiguity. The `keyId` is an opaque
2121
* rotation handle used to resolve the verifying public key.
2222
*
23+
* The algorithm is ENFORCED, not just labelled. node's `sign(null, …)` and
24+
* `verify(null, …)` follow whatever key they are handed, so an RSA or EC key
25+
* would otherwise sign and verify under the `ed25519` label. Both
26+
* {@link signPayload} and {@link verifyPayload} therefore refuse, by throwing,
27+
* any key whose type is not the signature's algorithm, and the refusal names
28+
* the key type found ({@link requireSignatureKeyType} is the one check).
29+
*
2330
* The two trust chains the runtime checks before loading a third-party
2431
* plugin are combined in {@link verifyPluginArtifact}.
2532
*/
@@ -30,19 +37,45 @@ import {
3037
createPublicKey,
3138
createPrivateKey,
3239
generateKeyPairSync,
33-
type KeyObject,
40+
KeyObject,
3441
} from 'node:crypto';
3542

3643
export const SIGNATURE_ALG = 'ed25519';
3744
const SIG_PREFIX = 'ed25519:';
3845

3946
export type KeyInput = string | KeyObject;
4047

48+
// A KeyObject is used as given; every other input (a PEM string, and the PEM
49+
// buffer / DER / JWK inputs node also accepts at runtime) is parsed into one,
50+
// so its key type can be read before anything is signed or verified.
4151
function toPrivateKey(key: KeyInput): KeyObject {
42-
return typeof key === 'string' ? createPrivateKey(key) : key;
52+
return key instanceof KeyObject ? key : createPrivateKey(key);
4353
}
4454
function toPublicKey(key: KeyInput): KeyObject {
45-
return typeof key === 'string' ? createPublicKey(key) : key;
55+
return key instanceof KeyObject ? key : createPublicKey(key);
56+
}
57+
58+
/**
59+
* The contract's one key rule: a key signs or verifies only when its type IS
60+
* the signature's algorithm. On the signing side that algorithm is
61+
* {@link SIGNATURE_ALG}; on the verifying side it is the label the signature
62+
* string carries ({@link ParsedSignature.alg}), so the label is checked against
63+
* the verifying key's type instead of being trusted. Throws, naming the key
64+
* type found (`rsa`, `ec`, `ed448`, … or `secret` for a symmetric key).
65+
*/
66+
function requireSignatureKeyType(
67+
key: KeyObject,
68+
alg: ParsedSignature['alg'],
69+
use: 'signPayload' | 'verifyPayload',
70+
): void {
71+
if (key.asymmetricKeyType === alg) return;
72+
const found = key.type === 'secret' ? 'secret' : String(key.asymmetricKeyType);
73+
const role = use === 'signPayload' ? 'private key' : 'public key';
74+
throw new Error(
75+
`${use}: the ${role} is of type '${found}', but plugin artifact signatures are '${alg}' only;` +
76+
` a key of that type would sign or verify a non-${alg} signature under the '${alg}' label.` +
77+
` Use an Ed25519 key (generate one with \`openssl genpkey -algorithm ed25519\`).`,
78+
);
4679
}
4780
function toBytes(payload: string | Uint8Array): Uint8Array {
4881
return typeof payload === 'string' ? new TextEncoder().encode(payload) : payload;
@@ -60,14 +93,19 @@ export function generateEd25519KeyPair(): { publicKeyPem: string; privateKeyPem:
6093
/**
6194
* Sign `payload` with an Ed25519 private key, returning the formatted
6295
* signature string `ed25519:<keyId>:<base64url(sig)>`.
96+
*
97+
* Throws when the private key is not Ed25519 (an RSA, EC, Ed448, … key), and
98+
* the error names the key type found.
6399
*/
64100
export function signPayload(
65101
payload: string | Uint8Array,
66102
privateKey: KeyInput,
67103
keyId = 'default',
68104
): string {
69105
if (keyId.includes(':')) throw new Error('keyId must not contain ":"');
70-
const sig = cryptoSign(null, toBytes(payload), toPrivateKey(privateKey));
106+
const key = toPrivateKey(privateKey);
107+
requireSignatureKeyType(key, SIGNATURE_ALG, 'signPayload');
108+
const sig = cryptoSign(null, toBytes(payload), key);
71109
return `${SIG_PREFIX}${keyId}:${sig.toString('base64url')}`;
72110
}
73111

@@ -93,16 +131,32 @@ export function parseSignature(s: string | undefined | null): ParsedSignature |
93131
}
94132
}
95133

96-
/** Verify a formatted signature string over `payload` with the given public key. */
134+
/**
135+
* Verify a formatted signature string over `payload` with the given public key.
136+
*
137+
* Returns `false` for a malformed signature string, an unreadable key, or a
138+
* signature that does not verify. Throws when the key's type is not the
139+
* algorithm the signature's label names (any key that is not Ed25519), and the
140+
* error names the key type found: such a key is the verifier's own trust
141+
* configuration being wrong, never a verdict on the signed bytes, so it is not
142+
* folded into `false`.
143+
*/
97144
export function verifyPayload(
98145
payload: string | Uint8Array,
99146
signature: string,
100147
publicKey: KeyInput,
101148
): boolean {
102149
const parsed = parseSignature(signature);
103150
if (!parsed) return false;
151+
let key: KeyObject;
152+
try {
153+
key = toPublicKey(publicKey);
154+
} catch {
155+
return false;
156+
}
157+
requireSignatureKeyType(key, parsed.alg, 'verifyPayload');
104158
try {
105-
return cryptoVerify(null, toBytes(payload), toPublicKey(publicKey), parsed.signature);
159+
return cryptoVerify(null, toBytes(payload), key, parsed.signature);
106160
} catch {
107161
return false;
108162
}
@@ -144,6 +198,8 @@ export interface PublisherVerifyResult {
144198
* - no signature → ok, verified=false (caller decides via trust tier).
145199
* - malformed / fails verification → NOT ok.
146200
* - unknown keyId → NOT ok (never silently trust).
201+
* - a resolved key that is not Ed25519 → throws (from {@link verifyPayload}):
202+
* the key registry is misconfigured, which is not a verdict on the artifact.
147203
*/
148204
export async function verifyPublisherSignature(
149205
args: { artifact: Uint8Array; signature?: string | null },
@@ -167,7 +223,10 @@ export async function verifyPublisherSignature(
167223
: { ok: false, verified: false, reason: 'publisher signature does not match artifact' };
168224
}
169225

170-
/** Verify a platform counter-signature against the version identity + platform public key. */
226+
/**
227+
* Verify a platform counter-signature against the version identity + platform
228+
* public key. Throws when the platform key is not Ed25519 (see {@link verifyPayload}).
229+
*/
171230
export function verifyPlatformSignature(
172231
version: {
173232
package_id: string;
@@ -196,6 +255,11 @@ export interface PluginArtifactVerifyResult {
196255
* marketplace attestation; the publisher signature additionally binds the
197256
* exact bytes. `requirePlatform` (default true) rejects artifacts that lack
198257
* a valid platform counter-sign — set false for first-party / local builds.
258+
*
259+
* Both verifying keys come from `keys` — the caller's platform key and its
260+
* publisher key registry; the artifact contributes only the signature strings
261+
* and the `keyId` that selects a registry entry. A configured key that is not
262+
* Ed25519 makes this reject (see {@link verifyPayload}) rather than resolve.
199263
*/
200264
export async function verifyPluginArtifact(
201265
input: {

0 commit comments

Comments
 (0)