From 503cd2c726f1e9c694fbf24d1313f0e1a640c7ae Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 7 May 2026 09:16:32 -0500 Subject: [PATCH] Avoid secrets provider lockfile drift Co-Authored-By: Paperclip --- server/package.json | 4 - .../aws-secrets-manager-provider.test.ts | 55 +++++++- .../secrets/aws-secrets-manager-provider.ts | 130 ++++++++++++++---- 3 files changed, 159 insertions(+), 30 deletions(-) diff --git a/server/package.json b/server/package.json index e3c93757d40..bd19e53fcf4 100644 --- a/server/package.json +++ b/server/package.json @@ -44,11 +44,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.888.0", - "@aws-sdk/credential-provider-node": "^3.972.10", "@paperclipai/adapter-acpx-local": "workspace:*", - "@smithy/hash-node": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", "@paperclipai/adapter-claude-local": "workspace:*", "@paperclipai/adapter-codex-local": "workspace:*", "@paperclipai/adapter-cursor-local": "workspace:*", diff --git a/server/src/__tests__/aws-secrets-manager-provider.test.ts b/server/src/__tests__/aws-secrets-manager-provider.test.ts index e1ca5ebc098..f5df61552fc 100644 --- a/server/src/__tests__/aws-secrets-manager-provider.test.ts +++ b/server/src/__tests__/aws-secrets-manager-provider.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createAwsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js"; import { SecretProviderClientError } from "../secrets/types.js"; @@ -9,9 +9,13 @@ describe("awsSecretsManagerProvider", () => { AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION, PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID: process.env.PAPERCLIP_SECRETS_AWS_DEPLOYMENT_ID, PAPERCLIP_SECRETS_AWS_KMS_KEY_ID: process.env.PAPERCLIP_SECRETS_AWS_KMS_KEY_ID, + AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, + AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY, + AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN, }; afterEach(() => { + vi.restoreAllMocks(); for (const [key, value] of Object.entries(previousEnv)) { if (value === undefined) { delete process.env[key]; @@ -164,6 +168,55 @@ describe("awsSecretsManagerProvider", () => { expect(prepared.externalRef).toContain("clip/prod-us-west/company-1/openai-api-key"); }); + it("signs AWS Secrets Manager JSON requests with default runtime credentials", async () => { + process.env.AWS_ACCESS_KEY_ID = "AKIA_TEST_ACCESS"; + process.env.AWS_SECRET_ACCESS_KEY = "test-secret-key"; + process.env.AWS_SESSION_TOKEN = "test-session-token"; + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + ARN: "arn:aws:secretsmanager:us-east-1:123456789012:secret:paperclip/prod/company-1/openai-api-key", + VersionId: "aws-version-1", + }), + { status: 200 }, + ), + ); + const provider = createAwsSecretsManagerProvider({ + config: { + region: "us-east-1", + endpoint: "https://secretsmanager.us-east-1.amazonaws.com", + deploymentId: "prod", + prefix: "paperclip", + kmsKeyId: "arn:aws:kms:us-east-1:123456789012:key/test", + environmentTag: "production", + providerOwnerTag: "paperclip", + deleteRecoveryWindowDays: 30, + }, + }); + + await provider.createSecret({ + value: "super-secret-value", + context: { + companyId: "company-1", + secretKey: "openai-api-key", + secretName: "OpenAI API Key", + version: 1, + }, + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]!; + const headers = init?.headers as Record; + expect(String(url)).toBe("https://secretsmanager.us-east-1.amazonaws.com/"); + expect(headers["x-amz-target"]).toBe("secretsmanager.CreateSecret"); + expect(headers["x-amz-security-token"]).toBe("test-session-token"); + expect(headers.authorization).toContain("Credential=AKIA_TEST_ACCESS/"); + expect(headers.authorization).toContain("/us-east-1/secretsmanager/aws4_request"); + expect(headers.authorization).toContain("SignedHeaders="); + expect(headers.authorization).toContain("Signature="); + }); + it("creates new AWS secret versions against a namespace-valid existing secret reference", async () => { const calls: Array<{ op: string; input: Record }> = []; const provider = createAwsSecretsManagerProvider({ diff --git a/server/src/secrets/aws-secrets-manager-provider.ts b/server/src/secrets/aws-secrets-manager-provider.ts index 9ca8c06fba9..149579d7f25 100644 --- a/server/src/secrets/aws-secrets-manager-provider.ts +++ b/server/src/secrets/aws-secrets-manager-provider.ts @@ -1,8 +1,5 @@ -import { createHash } from "node:crypto"; -import { defaultProvider as defaultAwsCredentialsProvider } from "@aws-sdk/credential-provider-node"; -import { Hash } from "@smithy/hash-node"; -import { HttpRequest } from "@smithy/protocol-http"; -import { SignatureV4 } from "@smithy/signature-v4"; +import { createHash, createHmac } from "node:crypto"; +import { S3Client } from "@aws-sdk/client-s3"; import type { DeploymentMode } from "@paperclipai/shared"; import { unprocessable } from "../errors.js"; import type { @@ -63,6 +60,12 @@ interface AwsSecretsManagerListSecretEntry { Tags?: AwsSecretsManagerTag[]; } +interface AwsCredentialIdentity { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; +} + type ManagedSecretNamespaceContext = Pick; interface AwsSecretsManagerGateway { @@ -117,6 +120,96 @@ function sha256Hex(value: string): string { return createHash("sha256").update(value).digest("hex"); } +function hmac(key: string | Buffer, value: string) { + return createHmac("sha256", key).update(value).digest(); +} + +function awsDateParts(now = new Date()) { + const iso = now.toISOString().replace(/[:-]|\.\d{3}/g, ""); + return { + amzDate: iso, + dateStamp: iso.slice(0, 8), + }; +} + +function canonicalHeaderValue(value: string) { + return value.trim().replace(/\s+/g, " "); +} + +function signAwsSecretsManagerRequest(input: { + endpoint: URL; + region: string; + operation: string; + body: string; + credentials: AwsCredentialIdentity; +}) { + const { amzDate, dateStamp } = awsDateParts(); + const payloadHash = sha256Hex(input.body); + const headers: Record = { + "content-type": "application/x-amz-json-1.1", + host: input.endpoint.host, + "x-amz-content-sha256": payloadHash, + "x-amz-date": amzDate, + "x-amz-target": `secretsmanager.${input.operation}`, + }; + if (input.credentials.sessionToken) { + headers["x-amz-security-token"] = input.credentials.sessionToken; + } + + const sortedHeaderNames = Object.keys(headers).sort(); + const canonicalHeaders = sortedHeaderNames + .map((name) => `${name}:${canonicalHeaderValue(headers[name] ?? "")}\n`) + .join(""); + const signedHeaders = sortedHeaderNames.join(";"); + const canonicalRequest = [ + "POST", + input.endpoint.pathname || "/", + "", + canonicalHeaders, + signedHeaders, + payloadHash, + ].join("\n"); + const credentialScope = `${dateStamp}/${input.region}/secretsmanager/aws4_request`; + const stringToSign = [ + "AWS4-HMAC-SHA256", + amzDate, + credentialScope, + sha256Hex(canonicalRequest), + ].join("\n"); + const dateKey = hmac(`AWS4${input.credentials.secretAccessKey}`, dateStamp); + const regionKey = hmac(dateKey, input.region); + const serviceKey = hmac(regionKey, "secretsmanager"); + const signingKey = hmac(serviceKey, "aws4_request"); + const signature = createHmac("sha256", signingKey).update(stringToSign).digest("hex"); + + return { + ...headers, + authorization: + `AWS4-HMAC-SHA256 Credential=${input.credentials.accessKeyId}/${credentialScope}, ` + + `SignedHeaders=${signedHeaders}, Signature=${signature}`, + }; +} + +async function loadAwsCredentials(region: string): Promise { + const client = new S3Client({ region }); + try { + const credentialSource = client.config.credentials; + const credentials = typeof credentialSource === "function" + ? await credentialSource() + : await credentialSource; + if (!credentials?.accessKeyId || !credentials.secretAccessKey) { + throw new Error("AWS SDK default credential provider chain did not return credentials"); + } + return { + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken, + }; + } finally { + client.destroy(); + } +} + function configuredAwsSecretsManagerDescriptor() { return { id: "aws_secrets_manager" as const, @@ -513,16 +606,9 @@ function normalizeAwsError(operation: string, error: unknown): never { class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway { private readonly endpoint: URL; - private readonly signer: SignatureV4; constructor(private readonly config: AwsSecretsManagerConfig) { this.endpoint = new URL(config.endpoint); - this.signer = new SignatureV4({ - credentials: defaultAwsCredentialsProvider(), - region: config.region, - service: "secretsmanager", - sha256: Hash.bind(null, "sha256"), - }); } createSecret(input: { @@ -587,23 +673,17 @@ class AwsSecretsManagerJsonGateway implements AwsSecretsManagerGateway { private async call(operation: string, payload: Record): Promise { const body = JSON.stringify(payload); - const request = new HttpRequest({ - protocol: this.endpoint.protocol, - hostname: this.endpoint.hostname, - port: this.endpoint.port ? Number(this.endpoint.port) : undefined, - method: "POST", - path: this.endpoint.pathname || "/", - headers: { - "content-type": "application/x-amz-json-1.1", - host: this.endpoint.host, - "x-amz-target": `secretsmanager.${operation}`, - }, + const credentials = await loadAwsCredentials(this.config.region); + const headers = signAwsSecretsManagerRequest({ + endpoint: this.endpoint, + region: this.config.region, + operation, body, + credentials, }); - const signed = await this.signer.sign(request); const response = await fetch(this.endpoint, { method: "POST", - headers: signed.headers as Record, + headers, body, }); const text = await response.text();