feat(connections): add connector protocol package

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 16:01:08 +00:00
1 parent 2aef4641b4
commit a19254d59b
7 files changed
+424

No files matched your search

+44
View File
@@ -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"
}
}
+123
View File
@@ -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));
});
});
+228
View File
@@ -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);
}
+8
View File
@@ -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"],
},
});
+13
View File
@@ -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':
+1
View File
@@ -3,6 +3,7 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
projects: [
"packages/connect-protocol",
"packages/shared",
"packages/skills-catalog",
"packages/db",