mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Avoid secrets provider lockfile drift
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
badb324d75
commit
503cd2c726
3 files changed
+159
-30
No files matched your search
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user