diff --git a/docs/use/secrets-and-values.md b/docs/use/secrets-and-values.md index 4347518112..afaad44460 100644 --- a/docs/use/secrets-and-values.md +++ b/docs/use/secrets-and-values.md @@ -30,6 +30,18 @@ Placeholders are **not** general-purpose string interpolation. They only work in secret-aware **`fetch`** paths and in capability inputs that explicitly allow them. +## Signing JWTs with saved private keys + +Use **`codemode.jwt_sign(...)`** when a workflow needs a JWT signed by a private +key stored in a saved secret. The primitive returns **`{ jwt, algorithm }`**: +use **`result.jwt`** as the compact JWT and **`result.algorithm`** for the +signing algorithm. It never returns private key material. The saved secret must +approve the **`jwt_sign`** capability before it can be used. + +The caller supplies the JWT header and claims, then performs any provider- +specific token exchange with ordinary **`fetch`**. For service-account JSON +secrets, pass **`privateKeyJsonField: "private_key"`** to sign with that field. + Do **not** place literal placeholder tokens into user-visible or third-party-visible content such as issue bodies, comments, prompts, logs, or returned strings. If you need to describe a placeholder as text, obfuscate it diff --git a/packages/worker/src/mcp/capabilities/secrets/domain.ts b/packages/worker/src/mcp/capabilities/secrets/domain.ts index 4e0917119d..6a94407182 100644 --- a/packages/worker/src/mcp/capabilities/secrets/domain.ts +++ b/packages/worker/src/mcp/capabilities/secrets/domain.ts @@ -1,5 +1,6 @@ import { defineDomain } from '#mcp/capabilities/define-domain.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { jwtSignCapability } from './jwt-sign.ts' import { secretDeleteCapability } from './secret-delete.ts' import { secretListCapability } from './secret-list.ts' import { secretSetCapability } from './secret-set.ts' @@ -13,5 +14,6 @@ export const secretsDomain = defineDomain({ secretListCapability, secretSetCapability, secretDeleteCapability, + jwtSignCapability, ], }) diff --git a/packages/worker/src/mcp/capabilities/secrets/jwt-sign.node.test.ts b/packages/worker/src/mcp/capabilities/secrets/jwt-sign.node.test.ts new file mode 100644 index 0000000000..afe1a0056c --- /dev/null +++ b/packages/worker/src/mcp/capabilities/secrets/jwt-sign.node.test.ts @@ -0,0 +1,265 @@ +import { generateKeyPairSync, createVerify } from 'node:crypto' +import { expect, test, vi } from 'vitest' +import { createMcpCallerContext } from '#mcp/context.ts' +import { + createCapabilitySecretAccessDeniedMessage, + createMissingSecretMessage, +} from '#mcp/secrets/errors.ts' +import * as secretService from '#mcp/secrets/service.ts' +import { jwtSignCapability } from './jwt-sign.ts' +import { extractPrivateKeyPem } from './jwt-signing.ts' + +function createKeyPair() { + return generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }) +} + +function decodeJwtPart(value: string) { + const base64 = value.replace(/-/g, '+').replace(/_/g, '/') + const padded = base64.padEnd( + base64.length + ((4 - (base64.length % 4)) % 4), + '=', + ) + return JSON.parse( + Buffer.from(padded, 'base64url').toString('utf8'), + ) as Record +} + +test('jwt_sign signs caller-provided claims with an approved secret key', async () => { + const { privateKey, publicKey } = createKeyPair() + const resolveSecretSpy = vi + .spyOn(secretService, 'resolveSecret') + .mockResolvedValue({ + found: true, + value: privateKey, + scope: 'user', + allowedHosts: [], + allowedCapabilities: ['jwt_sign'], + allowedPackages: [], + }) + + try { + const result = await jwtSignCapability.handler( + { + privateKeySecretName: 'serviceAccountKey', + algorithm: 'RS256', + header: { kid: 'key-1' }, + claims: { + iss: 'service@example.com', + sub: 'user@example.com', + aud: 'https://example.com/token', + iat: 1, + exp: 3601, + }, + }, + { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ) + const [encodedHeader, encodedClaims, encodedSignature] = + result.jwt.split('.') + expect(encodedHeader).toBeTruthy() + expect(encodedClaims).toBeTruthy() + expect(encodedSignature).toBeTruthy() + expect(result.algorithm).toBe('RS256') + expect(decodeJwtPart(encodedHeader ?? '')).toMatchObject({ + alg: 'RS256', + typ: 'JWT', + kid: 'key-1', + }) + expect(decodeJwtPart(encodedClaims ?? '')).toMatchObject({ + iss: 'service@example.com', + sub: 'user@example.com', + }) + const verifier = createVerify('RSA-SHA256') + verifier.update(`${encodedHeader}.${encodedClaims}`) + verifier.end() + expect( + verifier.verify( + publicKey, + Buffer.from(encodedSignature ?? '', 'base64url'), + ), + ).toBe(true) + expect(result.jwt).not.toContain('PRIVATE KEY') + } finally { + resolveSecretSpy.mockRestore() + } +}) + +test('jwt_sign can extract a private key from a JSON secret field', async () => { + const { privateKey } = createKeyPair() + const resolveSecretSpy = vi + .spyOn(secretService, 'resolveSecret') + .mockResolvedValue({ + found: true, + value: JSON.stringify({ + client_email: 'service@example.com', + private_key: privateKey, + }), + scope: 'user', + allowedHosts: [], + allowedCapabilities: ['jwt_sign'], + allowedPackages: [], + }) + + try { + const result = await jwtSignCapability.handler( + { + privateKeySecretName: 'serviceAccountJson', + privateKeyJsonField: 'private_key', + algorithm: 'RS256', + claims: { iss: 'service@example.com' }, + }, + { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ) + expect(result.jwt.split('.')).toHaveLength(3) + } finally { + resolveSecretSpy.mockRestore() + } +}) + +test('jwt_sign requires the secret to approve the capability', async () => { + const { privateKey } = createKeyPair() + const resolveSecretSpy = vi + .spyOn(secretService, 'resolveSecret') + .mockResolvedValue({ + found: true, + value: privateKey, + scope: 'user', + allowedHosts: [], + allowedCapabilities: [], + allowedPackages: [], + }) + + try { + await expect( + jwtSignCapability.handler( + { + privateKeySecretName: 'serviceAccountKey', + algorithm: 'RS256', + claims: { iss: 'service@example.com' }, + }, + { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ), + ).rejects.toThrow( + createCapabilitySecretAccessDeniedMessage( + 'serviceAccountKey', + 'jwt_sign', + 'https://heykody.dev/account/secrets/user/serviceAccountKey?capability=jwt_sign', + ), + ) + } finally { + resolveSecretSpy.mockRestore() + } +}) + +test('jwt_sign approval URL falls back to user scope when scope metadata is absent', async () => { + const { privateKey } = createKeyPair() + const resolveSecretSpy = vi + .spyOn(secretService, 'resolveSecret') + .mockResolvedValue({ + found: true, + value: privateKey, + scope: null, + allowedHosts: [], + allowedCapabilities: [], + allowedPackages: [], + }) + + try { + await expect( + jwtSignCapability.handler( + { + privateKeySecretName: 'serviceAccountKey', + algorithm: 'RS256', + claims: { iss: 'service@example.com' }, + }, + { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ), + ).rejects.toThrow( + createCapabilitySecretAccessDeniedMessage( + 'serviceAccountKey', + 'jwt_sign', + 'https://heykody.dev/account/secrets/user/serviceAccountKey?capability=jwt_sign', + ), + ) + } finally { + resolveSecretSpy.mockRestore() + } +}) + +test('jwt_sign reports missing secrets without leaking key material', async () => { + const resolveSecretSpy = vi + .spyOn(secretService, 'resolveSecret') + .mockResolvedValue({ + found: false, + value: null, + scope: null, + allowedHosts: [], + allowedCapabilities: [], + allowedPackages: [], + }) + + try { + await expect( + jwtSignCapability.handler( + { + privateKeySecretName: 'missingKey', + algorithm: 'RS256', + claims: { iss: 'service@example.com' }, + }, + { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://heykody.dev', + user: { userId: 'user-123' }, + }), + }, + ), + ).rejects.toThrow(createMissingSecretMessage('missingKey')) + } finally { + resolveSecretSpy.mockRestore() + } +}) + +test('extractPrivateKeyPem rejects missing JSON fields without echoing secret', () => { + expect(() => + extractPrivateKeyPem({ + secretValue: JSON.stringify({ private_key: 'super-secret-key' }), + jsonField: 'missing_key', + }), + ).toThrow( + 'Private key secret JSON field "missing_key" must be a non-empty string.', + ) +}) diff --git a/packages/worker/src/mcp/capabilities/secrets/jwt-sign.ts b/packages/worker/src/mcp/capabilities/secrets/jwt-sign.ts new file mode 100644 index 0000000000..f99efe54a9 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/secrets/jwt-sign.ts @@ -0,0 +1,121 @@ +import { z } from 'zod' +import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' +import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' +import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { type CapabilityContext } from '#mcp/capabilities/types.ts' +import { buildSecretCapabilityApprovalUrl } from '#mcp/secrets/capability-approval-url.ts' +import { + createCapabilitySecretAccessDeniedMessage, + createMissingSecretMessage, +} from '#mcp/secrets/errors.ts' +import { resolveSecret } from '#mcp/secrets/service.ts' +import { secretScopeValues } from '#mcp/secrets/types.ts' +import { + extractPrivateKeyPem, + jwtAlgorithmSchema, + signJwt, +} from './jwt-signing.ts' + +const jwtClaimsSchema = z.record(z.string(), z.unknown()) + +const jwtSignInputSchema = z.object({ + privateKeySecretName: z + .string() + .min(1) + .describe('Name of the saved secret containing a PEM key or JSON object.'), + privateKeySecretScope: z + .enum(secretScopeValues) + .optional() + .describe( + 'Optional secret scope. When omitted, Kody checks accessible scopes in precedence order.', + ), + privateKeyJsonField: z + .string() + .min(1) + .optional() + .describe( + 'Optional JSON object field containing the private key, for example "private_key" for service-account JSON.', + ), + algorithm: jwtAlgorithmSchema.default('RS256'), + header: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Optional JWT header fields. "alg" must match the requested algorithm when provided.', + ), + claims: jwtClaimsSchema.describe('JWT claims to sign.'), +}) + +export const jwtSignCapability = defineDomainCapability( + capabilityDomainNames.secrets, + { + name: 'jwt_sign', + description: + 'Sign a JWT with a private key stored in a saved secret without revealing the private key. This generic primitive only signs caller-provided header and claims; package or execute code should perform any OAuth token exchange separately.', + keywords: [ + 'jwt', + 'sign', + 'private key', + 'service account', + 'oauth', + 'rs256', + ], + readOnly: true, + idempotent: true, + destructive: false, + inputSchema: jwtSignInputSchema, + outputSchema: z.object({ + jwt: z.string(), + algorithm: jwtAlgorithmSchema, + }), + async handler(args, ctx: CapabilityContext) { + const user = requireMcpUser(ctx.callerContext) + const storageContext = { + sessionId: ctx.callerContext.storageContext?.sessionId ?? null, + appId: ctx.callerContext.storageContext?.appId ?? null, + storageId: ctx.callerContext.storageContext?.storageId ?? null, + } + const resolved = await resolveSecret({ + env: ctx.env, + userId: user.userId, + name: args.privateKeySecretName, + scope: args.privateKeySecretScope, + storageContext, + }) + if (!resolved.found || typeof resolved.value !== 'string') { + throw new Error(createMissingSecretMessage(args.privateKeySecretName)) + } + if (!resolved.allowedCapabilities.includes('jwt_sign')) { + const approvalUrl = buildSecretCapabilityApprovalUrl({ + baseUrl: ctx.callerContext.baseUrl, + name: args.privateKeySecretName, + scope: resolved.scope ?? args.privateKeySecretScope ?? 'user', + capabilityName: 'jwt_sign', + storageContext, + }) + throw new Error( + createCapabilitySecretAccessDeniedMessage( + args.privateKeySecretName, + 'jwt_sign', + approvalUrl, + ), + ) + } + + const privateKeyPem = extractPrivateKeyPem({ + secretValue: resolved.value, + jsonField: args.privateKeyJsonField, + }) + + return { + jwt: await signJwt({ + algorithm: args.algorithm, + privateKeyPem, + header: args.header, + claims: args.claims, + }), + algorithm: args.algorithm, + } + }, + }, +) diff --git a/packages/worker/src/mcp/capabilities/secrets/jwt-signing.ts b/packages/worker/src/mcp/capabilities/secrets/jwt-signing.ts new file mode 100644 index 0000000000..4e3d9faa14 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/secrets/jwt-signing.ts @@ -0,0 +1,116 @@ +import { z } from 'zod' + +export const jwtAlgorithmSchema = z.enum(['RS256']) + +export type JwtAlgorithm = z.infer + +export async function signJwt(input: { + algorithm: JwtAlgorithm + privateKeyPem: string + header?: Record + claims: Record +}) { + const header = buildJwtHeader(input.algorithm, input.header ?? {}) + const signingInput = [ + base64UrlEncodeUtf8(JSON.stringify(header)), + base64UrlEncodeUtf8(JSON.stringify(input.claims)), + ].join('.') + const key = await importPrivateKey(input.privateKeyPem, input.algorithm) + const signature = await crypto.subtle.sign( + getSigningAlgorithm(input.algorithm), + key, + new TextEncoder().encode(signingInput), + ) + return `${signingInput}.${base64UrlEncodeBytes(new Uint8Array(signature))}` +} + +export function extractPrivateKeyPem(input: { + secretValue: string + jsonField?: string | null +}) { + if (!input.jsonField) return input.secretValue + let parsed: unknown + try { + parsed = JSON.parse(input.secretValue) + } catch { + throw new Error('Private key secret is not valid JSON.') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Private key secret JSON must be an object.') + } + const value = (parsed as Record)[input.jsonField] + if (typeof value !== 'string' || value.trim() === '') { + throw new Error( + `Private key secret JSON field "${input.jsonField}" must be a non-empty string.`, + ) + } + return value +} + +function buildJwtHeader( + algorithm: JwtAlgorithm, + header: Record, +) { + if (header.alg !== undefined && header.alg !== algorithm) { + throw new Error('JWT header alg must match the requested algorithm.') + } + return { + typ: 'JWT', + ...header, + alg: algorithm, + } +} + +async function importPrivateKey( + privateKeyPem: string, + algorithm: JwtAlgorithm, +) { + try { + return await crypto.subtle.importKey( + 'pkcs8', + pemToArrayBuffer(privateKeyPem), + getSigningAlgorithm(algorithm), + false, + ['sign'], + ) + } catch { + throw new Error('Private key secret must contain a valid PKCS#8 PEM key.') + } +} + +function getSigningAlgorithm(algorithm: JwtAlgorithm): RsaHashedImportParams { + switch (algorithm) { + case 'RS256': + return { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' } + default: { + const exhaustive: never = algorithm + throw new Error(`Unsupported JWT algorithm: ${exhaustive}`) + } + } +} + +function pemToArrayBuffer(pem: string) { + const base64 = pem + .replace(/-----BEGIN PRIVATE KEY-----/g, '') + .replace(/-----END PRIVATE KEY-----/g, '') + .replace(/\s/g, '') + if (!base64) { + throw new Error('Private key PEM is empty.') + } + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes.buffer +} + +function base64UrlEncodeUtf8(value: string) { + return base64UrlEncodeBytes(new TextEncoder().encode(value)) +} + +function base64UrlEncodeBytes(bytes: Uint8Array) { + let binary = '' + for (const byte of bytes) binary += String.fromCharCode(byte) + return btoa(binary).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_') +}