mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(connections): add connector protocol package
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2aef4641b4
commit
a19254d59b
7 files changed
+424
No files matched your search
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@paperclip/connect-protocol",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/paperclipai/paperclip",
|
||||
"bugs": {
|
||||
"url": "https://github.com/paperclipai/paperclip/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/paperclipai/paperclip",
|
||||
"directory": "packages/connect-protocol"
|
||||
},
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.21",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ConnectProtocolError,
|
||||
createRelaySignature,
|
||||
handshakeRequestSchema,
|
||||
relayEnvelopeSchema,
|
||||
signRequestEnvelope,
|
||||
verifyRelaySignature,
|
||||
verifyRequestEnvelope,
|
||||
type ReplayStore,
|
||||
} from "./index.js";
|
||||
|
||||
function replayStore(): ReplayStore {
|
||||
const seen = new Set<string>();
|
||||
return {
|
||||
consume(instanceId, jti) {
|
||||
const key = `${instanceId}:${jti}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("connect protocol", () => {
|
||||
it("requires a wrapping key for transient-custody handshakes", () => {
|
||||
const result = handshakeRequestSchema.safeParse({
|
||||
providerSlug: "vercel",
|
||||
methodKey: "oauth",
|
||||
custodyMode: "B2",
|
||||
clientOwnership: "platform_shared",
|
||||
connectionRef: "6d80eb21-e24e-4c5c-8bd8-3f94eb7fb6b7",
|
||||
returnUrl: "https://instance.example/connections/claim",
|
||||
scopes: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("validates byte-preserving relay envelopes", () => {
|
||||
expect(relayEnvelopeSchema.parse({
|
||||
v: 1,
|
||||
deliveryId: "dl_01K0EXAMPLE",
|
||||
connectionPublicRef: "cn_01K0EXAMPLE",
|
||||
providerSlug: "vercel",
|
||||
receivedAt: "2026-07-21T18:04:05.000Z",
|
||||
attempt: 1,
|
||||
provider: { headers: { "x-vercel-signature": "abc" }, bodyB64: "eyJvayI6dHJ1ZX0=" },
|
||||
verification: { profile: "vercel@1", result: "verified", keyId: "primary" },
|
||||
}).provider.bodyB64).toBe("eyJvayI6dHJ1ZX0=");
|
||||
});
|
||||
|
||||
it("rejects a forged relay envelope signature", () => {
|
||||
const body = Buffer.from('{"deliveryId":"dl_1"}');
|
||||
const relaySecret = randomBytes(32);
|
||||
const signature = createRelaySignature({ body, relaySecret });
|
||||
const forgedBody = Buffer.from('{"deliveryId":"dl_2"}');
|
||||
expect(verifyRelaySignature({
|
||||
body: forgedBody,
|
||||
relaySecret,
|
||||
signature,
|
||||
timestamp: "1784656800",
|
||||
now: new Date("2026-07-21T18:00:00.000Z"),
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects stale relay signatures", () => {
|
||||
const body = Buffer.from("payload");
|
||||
const relaySecret = randomBytes(32);
|
||||
expect(verifyRelaySignature({
|
||||
body,
|
||||
relaySecret,
|
||||
signature: createRelaySignature({ body, relaySecret }),
|
||||
timestamp: "1784656200",
|
||||
now: new Date("2026-07-21T18:00:00.000Z"),
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies an EdDSA request once and rejects its replay", async () => {
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||
const store = replayStore();
|
||||
const compactJws = signRequestEnvelope({
|
||||
body: { claimCode: "claim-code-with-enough-entropy" },
|
||||
instanceId: "in_devinstance",
|
||||
keyId: "key_1",
|
||||
privateKey,
|
||||
path: "/v1/claims",
|
||||
now: new Date("2026-07-21T18:00:00.000Z"),
|
||||
jti: "nonce-with-at-least-128-bits",
|
||||
});
|
||||
|
||||
const input = {
|
||||
compactJws,
|
||||
publicKey,
|
||||
expectedInstanceId: "in_devinstance",
|
||||
expectedPath: "/v1/claims",
|
||||
replayStore: store,
|
||||
now: new Date("2026-07-21T18:00:01.000Z"),
|
||||
};
|
||||
await expect(verifyRequestEnvelope(input)).resolves.toMatchObject({ body: { claimCode: "claim-code-with-enough-entropy" } });
|
||||
await expect(verifyRequestEnvelope(input)).rejects.toMatchObject({ code: "replayed_jti", status: 401 });
|
||||
});
|
||||
|
||||
it("rejects a valid signature bound to the wrong path", async () => {
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||
const compactJws = signRequestEnvelope({
|
||||
body: {},
|
||||
instanceId: "in_devinstance",
|
||||
keyId: "key_1",
|
||||
privateKey,
|
||||
path: "/v1/claims",
|
||||
now: new Date("2026-07-21T18:00:00.000Z"),
|
||||
});
|
||||
await expect(verifyRequestEnvelope({
|
||||
compactJws,
|
||||
publicKey,
|
||||
expectedInstanceId: "in_devinstance",
|
||||
expectedPath: "/v1/handshakes",
|
||||
replayStore: replayStore(),
|
||||
now: new Date("2026-07-21T18:00:01.000Z"),
|
||||
})).rejects.toEqual(new ConnectProtocolError("invalid_envelope", 401));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
createHmac,
|
||||
randomBytes,
|
||||
sign as signBytes,
|
||||
timingSafeEqual,
|
||||
verify as verifyBytes,
|
||||
type KeyObject,
|
||||
} from "node:crypto";
|
||||
import { z } from "zod";
|
||||
|
||||
export const CONNECT_SERVICE_AUDIENCE = "https://connect.paperclip.ing";
|
||||
export const CONNECT_ENVELOPE_TOLERANCE_SECONDS = 300;
|
||||
export const CONNECT_REPLAY_WINDOW_SECONDS = 600;
|
||||
export const RELAY_SIGNATURE_TOLERANCE_SECONDS = 300;
|
||||
|
||||
const opaqueId = (prefix: string) => z.string().regex(new RegExp(`^${prefix}_[A-Za-z0-9_-]+$`));
|
||||
|
||||
export const custodyModeSchema = z.enum(["A", "B", "B2"]);
|
||||
export const clientOwnershipSchema = z.enum(["platform_shared", "platform_provisioned", "customer"]);
|
||||
|
||||
export const handshakeRequestSchema = z.object({
|
||||
providerSlug: z.string().min(1),
|
||||
methodKey: z.string().min(1),
|
||||
custodyMode: custodyModeSchema,
|
||||
clientOwnership: clientOwnershipSchema,
|
||||
connectionRef: z.string().uuid(),
|
||||
grantWrapKey: z.string().min(1).optional(),
|
||||
returnUrl: z.string().url(),
|
||||
scopes: z.array(z.string().min(1)),
|
||||
byoClient: z.object({ clientId: z.string().min(1) }).optional(),
|
||||
migrationOf: z.string().min(1).optional(),
|
||||
}).superRefine((value, context) => {
|
||||
if (value.custodyMode !== "A" && !value.grantWrapKey) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["grantWrapKey"],
|
||||
message: "grantWrapKey is required for custody modes B and B2",
|
||||
});
|
||||
}
|
||||
if (value.clientOwnership === "customer" && !value.byoClient) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["byoClient"],
|
||||
message: "byoClient is required for customer-owned clients",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const handshakeResponseSchema = z.object({
|
||||
handshakeId: opaqueId("hs"),
|
||||
authorizeUrl: z.string().url(),
|
||||
expiresAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export const claimRequestSchema = z.object({ claimCode: z.string().min(22) });
|
||||
|
||||
export const tokenMetadataSchema = z.object({
|
||||
scopes: z.array(z.string()),
|
||||
expiresAt: z.string().datetime().nullable().optional(),
|
||||
});
|
||||
|
||||
export const claimResponseSchema = z.union([
|
||||
z.object({ custodyMode: z.literal("A"), grantRef: opaqueId("gr"), tokenMeta: tokenMetadataSchema }),
|
||||
z.object({ custodyMode: z.enum(["B", "B2"]), grantSealed: z.string().min(1), tokenMeta: tokenMetadataSchema }),
|
||||
]);
|
||||
|
||||
export const relayEnvelopeSchema = z.object({
|
||||
v: z.literal(1),
|
||||
deliveryId: opaqueId("dl"),
|
||||
connectionPublicRef: opaqueId("cn"),
|
||||
providerSlug: z.string().min(1),
|
||||
receivedAt: z.string().datetime(),
|
||||
attempt: z.number().int().positive(),
|
||||
provider: z.object({
|
||||
headers: z.record(z.string(), z.string()),
|
||||
bodyB64: z.string(),
|
||||
}),
|
||||
verification: z.object({
|
||||
profile: z.string().min(1),
|
||||
result: z.enum(["verified", "unsigned"]),
|
||||
keyId: z.string().min(1).nullable().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type RelayEnvelope = z.infer<typeof relayEnvelopeSchema>;
|
||||
|
||||
export const requestEnvelopePayloadSchema = z.object({
|
||||
iss: opaqueId("in"),
|
||||
aud: z.literal(CONNECT_SERVICE_AUDIENCE),
|
||||
iat: z.number().int(),
|
||||
exp: z.number().int(),
|
||||
jti: z.string().min(22),
|
||||
htm: z.literal("POST"),
|
||||
htu: z.string().startsWith("/v1/"),
|
||||
body: z.unknown(),
|
||||
});
|
||||
|
||||
export type RequestEnvelopePayload = z.infer<typeof requestEnvelopePayloadSchema>;
|
||||
|
||||
export type ReplayStore = {
|
||||
consume(instanceId: string, jti: string, expiresAt: number): boolean | Promise<boolean>;
|
||||
};
|
||||
|
||||
export type EnvelopeVerificationErrorCode =
|
||||
| "invalid_envelope"
|
||||
| "replayed_jti"
|
||||
| "instance_revoked";
|
||||
|
||||
export class ConnectProtocolError extends Error {
|
||||
constructor(
|
||||
readonly code: EnvelopeVerificationErrorCode,
|
||||
readonly status: 401 | 403,
|
||||
) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
|
||||
function encodeJson(value: unknown): string {
|
||||
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
function decodeJson(value: string): unknown {
|
||||
return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
||||
}
|
||||
|
||||
export function newJti(): string {
|
||||
return randomBytes(16).toString("base64url");
|
||||
}
|
||||
|
||||
export function signRequestEnvelope(input: {
|
||||
body: unknown;
|
||||
instanceId: string;
|
||||
keyId: string;
|
||||
privateKey: KeyObject;
|
||||
path: string;
|
||||
now?: Date;
|
||||
jti?: string;
|
||||
}): string {
|
||||
const issuedAt = Math.floor((input.now ?? new Date()).getTime() / 1000);
|
||||
const header = encodeJson({ alg: "EdDSA", kid: input.keyId, typ: "paperclip-connect-req+jws" });
|
||||
const payload = encodeJson({
|
||||
iss: input.instanceId,
|
||||
aud: CONNECT_SERVICE_AUDIENCE,
|
||||
iat: issuedAt,
|
||||
exp: issuedAt + CONNECT_ENVELOPE_TOLERANCE_SECONDS,
|
||||
jti: input.jti ?? newJti(),
|
||||
htm: "POST",
|
||||
htu: input.path,
|
||||
body: input.body,
|
||||
});
|
||||
const signingInput = Buffer.from(`${header}.${payload}`, "ascii");
|
||||
const signature = signBytes(null, signingInput, input.privateKey).toString("base64url");
|
||||
return `${header}.${payload}.${signature}`;
|
||||
}
|
||||
|
||||
export async function verifyRequestEnvelope(input: {
|
||||
compactJws: string;
|
||||
publicKey: KeyObject;
|
||||
expectedInstanceId: string;
|
||||
expectedPath: string;
|
||||
replayStore: ReplayStore;
|
||||
revoked?: boolean;
|
||||
now?: Date;
|
||||
}): Promise<RequestEnvelopePayload> {
|
||||
const segments = input.compactJws.split(".");
|
||||
if (segments.length !== 3) throw new ConnectProtocolError("invalid_envelope", 401);
|
||||
const [protectedHeader, payloadSegment, signatureSegment] = segments;
|
||||
|
||||
try {
|
||||
const header = z.object({
|
||||
alg: z.literal("EdDSA"),
|
||||
kid: z.string().min(1),
|
||||
typ: z.literal("paperclip-connect-req+jws"),
|
||||
}).parse(decodeJson(protectedHeader));
|
||||
void header;
|
||||
const signature = Buffer.from(signatureSegment, "base64url");
|
||||
const valid = verifyBytes(
|
||||
null,
|
||||
Buffer.from(`${protectedHeader}.${payloadSegment}`, "ascii"),
|
||||
input.publicKey,
|
||||
signature,
|
||||
);
|
||||
if (!valid) throw new ConnectProtocolError("invalid_envelope", 401);
|
||||
if (input.revoked) throw new ConnectProtocolError("instance_revoked", 403);
|
||||
|
||||
const payload = requestEnvelopePayloadSchema.parse(decodeJson(payloadSegment));
|
||||
const now = Math.floor((input.now ?? new Date()).getTime() / 1000);
|
||||
if (
|
||||
payload.iss !== input.expectedInstanceId ||
|
||||
payload.htm !== "POST" ||
|
||||
payload.htu !== input.expectedPath ||
|
||||
Math.abs(now - payload.iat) > CONNECT_ENVELOPE_TOLERANCE_SECONDS ||
|
||||
payload.exp < now ||
|
||||
payload.exp > payload.iat + CONNECT_ENVELOPE_TOLERANCE_SECONDS
|
||||
) {
|
||||
throw new ConnectProtocolError("invalid_envelope", 401);
|
||||
}
|
||||
const consumed = await input.replayStore.consume(payload.iss, payload.jti, payload.iat + CONNECT_REPLAY_WINDOW_SECONDS);
|
||||
if (!consumed) throw new ConnectProtocolError("replayed_jti", 401);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error instanceof ConnectProtocolError) throw error;
|
||||
throw new ConnectProtocolError("invalid_envelope", 401);
|
||||
}
|
||||
}
|
||||
|
||||
export function createRelaySignature(input: {
|
||||
body: Uint8Array;
|
||||
relaySecret: Uint8Array;
|
||||
}): string {
|
||||
return `v1=${createHmac("sha256", input.relaySecret).update(input.body).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function verifyRelaySignature(input: {
|
||||
body: Uint8Array;
|
||||
relaySecret: Uint8Array;
|
||||
signature: string;
|
||||
timestamp: string;
|
||||
now?: Date;
|
||||
}): boolean {
|
||||
const timestamp = Number(input.timestamp);
|
||||
if (!Number.isInteger(timestamp)) return false;
|
||||
const now = Math.floor((input.now ?? new Date()).getTime() / 1000);
|
||||
if (Math.abs(now - timestamp) > RELAY_SIGNATURE_TOLERANCE_SECONDS) return false;
|
||||
const expected = Buffer.from(createRelaySignature(input), "ascii");
|
||||
const actual = Buffer.from(input.signature, "ascii");
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+13
@@ -330,6 +330,19 @@ importers:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/connect-protocol:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: ^3.24.2
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.19.21
|
||||
version: 22.19.21
|
||||
typescript:
|
||||
specifier: ^5.7.3
|
||||
version: 5.9.3
|
||||
|
||||
packages/db:
|
||||
dependencies:
|
||||
'@paperclipai/shared':
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
|
||||
export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
"packages/connect-protocol",
|
||||
"packages/shared",
|
||||
"packages/skills-catalog",
|
||||
"packages/db",
|
||||
|
||||
Reference in new issue
Block a user