mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
feat(runtime-exposure): least-privilege Tailscale HTTPS broker + shared contract (PAP-17051)
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>
This commit is contained in:
1 parent
6e14ed3031
commit
ec7adcaf3d
36 files changed
+3293
No files matched your search
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in new issue
Block a user