diff --git a/packages/connect-protocol/package.json b/packages/connect-protocol/package.json new file mode 100644 index 00000000000..edfc5373a0b --- /dev/null +++ b/packages/connect-protocol/package.json @@ -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" + } +} diff --git a/packages/connect-protocol/src/index.test.ts b/packages/connect-protocol/src/index.test.ts new file mode 100644 index 00000000000..743edfba12e --- /dev/null +++ b/packages/connect-protocol/src/index.test.ts @@ -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(); + 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)); + }); +}); diff --git a/packages/connect-protocol/src/index.ts b/packages/connect-protocol/src/index.ts new file mode 100644 index 00000000000..0931df92dae --- /dev/null +++ b/packages/connect-protocol/src/index.ts @@ -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; + +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; + +export type ReplayStore = { + consume(instanceId: string, jti: string, expiresAt: number): boolean | Promise; +}; + +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 { + 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); +} diff --git a/packages/connect-protocol/tsconfig.json b/packages/connect-protocol/tsconfig.json new file mode 100644 index 00000000000..5a24989cd30 --- /dev/null +++ b/packages/connect-protocol/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/connect-protocol/vitest.config.ts b/packages/connect-protocol/vitest.config.ts new file mode 100644 index 00000000000..ae847ff6d9c --- /dev/null +++ b/packages/connect-protocol/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21d211f48d1..1b6b49c1aa1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/vitest.config.ts b/vitest.config.ts index 114dd2094cf..65c1b1a98de 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { projects: [ + "packages/connect-protocol", "packages/shared", "packages/skills-catalog", "packages/db",