mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
Foundation for opt-in `tailscale_https` exposure of managed branch runtimes, implementing the PAP-17049 plan under the PAP-17050 threat-model verdict. Broker (packages/tailscale-https-broker): root/dedicated-account host broker exposing only idempotent list/expose/remove over a Unix socket. Implements the six required verdict controls: SO_PEERCRED + UID/GID allowlist + unguessable broker-issued lease handles (req #1); dedicated port allowlist + /proc listener ownership/loopback proof before mutation (req #2); per-mutation transaction (strict-parse serve JSON -> verify absence/exact-lease -> verify :443 -> one fixed per-port op -> reread -> require only-intended-changed + :443 identical -> atomic root-owned registry commit, quarantine-on-ambiguity) (req #3); execve/shell=false argv built only from canonical integers + fixed tokens, pinned CLI schema (req #4); versioned length-bounded strict-JSON protocol (req #5); bounded append-only audit events (req #6). Deny-by-default for Funnel/cert/Services/reset/set-config/path-handlers/:443/reserved ports. Shared contract (packages/shared): single source of truth for exposure config, lifecycle state (pending/ready/failed/cleanup_pending/removed), listener ownership, and status, with strict zod validators and deterministic app/HMR port-pair + URL derivation so DB/server/UI/runtime cannot drift. Tests: broker 57/57, shared runtime-exposure 13/13, both typecheck clean. Server/DB/UI lifecycle integration + docs land in follow-on commits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
/**
|
|
* Peer authorization and lease-handle ownership (PAP-17050 verdict req #1).
|
|
*
|
|
* SO_PEERCRED authenticates an OS principal; a caller-supplied runtime ID does
|
|
* NOT prove ownership. Authorization therefore has two layers:
|
|
* 1. An exact UID/GID allowlist checked with peer credentials on EVERY
|
|
* accepted connection (complete mediation).
|
|
* 2. Ownership defined as a broker-issued, unguessable lease handle returned
|
|
* by `expose` and required by `remove`, bound to peer identity, runtime
|
|
* UUID, ports, and generation. `list` never returns handles.
|
|
*/
|
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
import type { LeaseRecord, PeerCredentials } from "./types.js";
|
|
|
|
export interface PeerPolicy {
|
|
/** Exact set of allowed peer UIDs (the Paperclip service identity). */
|
|
allowedUids: ReadonlySet<number>;
|
|
/** Exact set of allowed peer GIDs (the dedicated broker socket group). */
|
|
allowedGids: ReadonlySet<number>;
|
|
}
|
|
|
|
export class AuthorizationError extends Error {
|
|
constructor(
|
|
readonly code:
|
|
| "unauthorized_peer"
|
|
| "invalid_handle"
|
|
| "listener_ownership_mismatch",
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
this.name = "AuthorizationError";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Complete-mediation check run on every accepted connection before any request
|
|
* is even decoded. Throws AuthorizationError("unauthorized_peer") on any
|
|
* mismatch. Supplemental-group-only membership does not satisfy the GID check
|
|
* because peer.gid is the process's primary GID from SO_PEERCRED.
|
|
*/
|
|
export function authorizePeer(peer: PeerCredentials, policy: PeerPolicy): void {
|
|
if (!Number.isInteger(peer.uid) || !Number.isInteger(peer.gid)) {
|
|
throw new AuthorizationError("unauthorized_peer", "missing peer credentials");
|
|
}
|
|
if (!policy.allowedUids.has(peer.uid)) {
|
|
throw new AuthorizationError("unauthorized_peer", `uid ${peer.uid} not allowlisted`);
|
|
}
|
|
if (!policy.allowedGids.has(peer.gid)) {
|
|
throw new AuthorizationError("unauthorized_peer", `gid ${peer.gid} not allowlisted`);
|
|
}
|
|
}
|
|
|
|
/** Generate an unguessable lease handle (256 bits, url-safe). */
|
|
export function generateLeaseHandle(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
/** Constant-time handle comparison to avoid timing oracles. */
|
|
export function handlesEqual(a: string, b: string): boolean {
|
|
const ab = Buffer.from(a, "utf8");
|
|
const bb = Buffer.from(b, "utf8");
|
|
if (ab.byteLength !== bb.byteLength) return false;
|
|
return timingSafeEqual(ab, bb);
|
|
}
|
|
|
|
/**
|
|
* Resolve the lease a `remove` request is authorized to act on. Requires an
|
|
* exact handle match AND that the requesting peer + runtime UUID match the
|
|
* lease bound at expose time. Runtime A can never remove runtime B's listener.
|
|
*/
|
|
export function authorizeRemoval(
|
|
leases: readonly LeaseRecord[],
|
|
request: { runtimeId: string; handle: string },
|
|
peer: PeerCredentials,
|
|
): LeaseRecord {
|
|
const lease = leases.find((entry) => handlesEqual(entry.handle, request.handle));
|
|
if (!lease) {
|
|
throw new AuthorizationError("invalid_handle", "unknown or stale lease handle");
|
|
}
|
|
if (lease.runtimeId !== request.runtimeId) {
|
|
throw new AuthorizationError("listener_ownership_mismatch", "runtime id does not match lease");
|
|
}
|
|
if (lease.peerUid !== peer.uid || lease.peerGid !== peer.gid) {
|
|
throw new AuthorizationError("listener_ownership_mismatch", "peer identity does not match lease");
|
|
}
|
|
return lease;
|
|
}
|