Avoid secrets provider lockfile drift

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-07 18:59:05 -05:00
1 parent badb324d75
commit 503cd2c726
3 files changed
+159 -30

No files matched your search

-4
View File
@@ -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:*",
@@ -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<string, string>;
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<string, unknown> }> = [];
const provider = createAwsSecretsManagerProvider({
@@ -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<SecretProviderWriteContext, "companyId" | "secretKey">;
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<string, string> = {
"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<AwsCredentialIdentity> {
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<T>(operation: string, payload: Record<string, unknown>): Promise<T> {
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<string, string>,
headers,
body,
});
const text = await response.text();