feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation
Customers install one command / log in once; hardware-generated keys never leave the device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12, no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md. Control-plane / PKI (control-plane/): - ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256) - ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing - ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers - ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert) - registry/devices.ts: device registry + per-account cap/rate-limit - auth/session.ts: device:enroll capability token mint/verify - api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default) - pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm - boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast) Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind) Host agent (agent/): - transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract) - keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE - net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient + Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap) Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403 Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85, iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy, VPS frps, physical iPhone) per PROGRESS_LOG runbook. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
159
agent/src/provision/untar.ts
Normal file
159
agent/src/provision/untar.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Minimal, path-traversal-hardened tar extractor for the frp release archive — TASK B3 extraction
|
||||
* step (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain).
|
||||
*
|
||||
* frp's GitHub releases ship a `.tar.gz` whose payload is `frp_<ver>_<os>_<arch>/{frpc,frps,...}` —
|
||||
* the host cannot exec a `.tar.gz`, so after `frpcBinary.ts` has VERIFIED the archive's SHA-256
|
||||
* against its pin it hands the bytes here to pull out ONLY the inner `frpc`.
|
||||
*
|
||||
* SECURITY (§5): this does NOT reconstruct the archive's directory tree on disk. The caller writes
|
||||
* the returned bytes to a FIXED destination it owns; entries are selected purely by matching the
|
||||
* (sanitized) tar name's BASENAME. Any candidate entry whose name is absolute, contains a NUL, or
|
||||
* has a `..` path segment is REJECTED (never silently skipped, so a `../frpc` decoy cannot be used
|
||||
* to derive a path). A malicious tarball can therefore never cause a write outside the caller's dir.
|
||||
*
|
||||
* Format scope: real frp archives are plain USTAR (magic `ustar `) with 100-byte names (no PAX/GNU
|
||||
* long-name/sparse entries) — verified against frp v0.61.1. The parser reads only what USTAR needs:
|
||||
* name (0..100), size octal (124..136), typeflag (156); regular files are typeflag `0` or legacy NUL.
|
||||
*/
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
const TAR_BLOCK = 512
|
||||
const NAME_OFFSET = 0
|
||||
const NAME_LEN = 100
|
||||
const SIZE_OFFSET = 124
|
||||
const SIZE_LEN = 12
|
||||
const TYPEFLAG_OFFSET = 156
|
||||
|
||||
// USTAR regular-file type flags: '0' (0x30) and the legacy NUL (0x00). Everything else (dir '5',
|
||||
// symlink '2', PAX 'x'/'g', GNU 'L', …) is not a plain file and is skipped when matching.
|
||||
const TYPE_REGULAR = 0x30
|
||||
const TYPE_LEGACY = 0x00
|
||||
|
||||
// Hard ceiling on a single extracted entry. frp's `frpc` is ~14 MB; 256 MB rejects a corrupt/hostile
|
||||
// size field before it can drive a huge allocation or an out-of-bounds read.
|
||||
const MAX_ENTRY_BYTES = 256 * 1024 * 1024
|
||||
|
||||
const FRPC_BASENAME = 'frpc'
|
||||
|
||||
/** A tar/gzip extraction failure (corrupt gzip, malformed/truncated tar, unsafe or missing entry). */
|
||||
export class TarExtractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'TarExtractError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True if `name` could escape a destination directory: contains a NUL, is an absolute path, or has
|
||||
* any `..` path segment. Both `/` and `\` are treated as separators (defense in depth).
|
||||
*/
|
||||
function isUnsafeEntryName(name: string): boolean {
|
||||
if (name.length === 0) return true
|
||||
if (name.includes('\0')) return true
|
||||
if (name.startsWith('/') || name.startsWith('\\')) return true
|
||||
return name.split(/[/\\]/).some((segment) => segment === '..')
|
||||
}
|
||||
|
||||
/** Last `/`- or `\`-separated segment of a tar entry name. */
|
||||
function basename(name: string): string {
|
||||
const parts = name.split(/[/\\]/)
|
||||
return parts[parts.length - 1] ?? name
|
||||
}
|
||||
|
||||
/** Decode the NUL-terminated USTAR name field of the header block at `off`. */
|
||||
function readName(tar: Uint8Array, off: number): string {
|
||||
const raw = tar.subarray(off + NAME_OFFSET, off + NAME_OFFSET + NAME_LEN)
|
||||
const nul = raw.indexOf(0)
|
||||
return new TextDecoder().decode(raw.subarray(0, nul < 0 ? NAME_LEN : nul))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a NUL/space-terminated octal numeric field. Leading spaces are tolerated (GNU pads them);
|
||||
* any non-octal digit yields `NaN` so the caller can reject a corrupt header.
|
||||
*/
|
||||
function readOctal(tar: Uint8Array, start: number, len: number): number {
|
||||
let value = 0
|
||||
let seenDigit = false
|
||||
for (let i = start; i < start + len; i++) {
|
||||
const c = tar[i]
|
||||
if (c === undefined || c === 0x00 || c === 0x20) {
|
||||
if (seenDigit) break
|
||||
continue
|
||||
}
|
||||
if (c < 0x30 || c > 0x37) return Number.NaN
|
||||
value = value * 8 + (c - 0x30)
|
||||
seenDigit = true
|
||||
}
|
||||
return seenDigit ? value : 0
|
||||
}
|
||||
|
||||
/** True if the 512-byte header block at `off` is entirely zero (the end-of-archive marker). */
|
||||
function isZeroBlock(tar: Uint8Array, off: number): boolean {
|
||||
for (let i = off; i < off + TAR_BLOCK; i++) {
|
||||
if (tar[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the bytes of the FIRST regular-file entry whose safe basename equals `targetBasename`.
|
||||
*
|
||||
* Selection is by basename only — the archive's directory structure is never mapped to a path. A
|
||||
* candidate whose name is unsafe (absolute / NUL / `..`) throws rather than being used. Missing
|
||||
* entry, a truncated/corrupt tar, or an over-size entry all throw `TarExtractError` (place nothing).
|
||||
*/
|
||||
export function extractTarFileByBasename(tar: Uint8Array, targetBasename: string): Uint8Array {
|
||||
let off = 0
|
||||
while (off + TAR_BLOCK <= tar.length) {
|
||||
if (isZeroBlock(tar, off)) break // end-of-archive
|
||||
|
||||
const size = readOctal(tar, off + SIZE_OFFSET, SIZE_LEN)
|
||||
if (!Number.isInteger(size) || size < 0) {
|
||||
throw new TarExtractError('corrupt tar: invalid entry size field')
|
||||
}
|
||||
if (size > MAX_ENTRY_BYTES) {
|
||||
throw new TarExtractError(`tar entry exceeds max size (${size} > ${MAX_ENTRY_BYTES})`)
|
||||
}
|
||||
|
||||
const contentStart = off + TAR_BLOCK
|
||||
const contentEnd = contentStart + size
|
||||
if (contentEnd > tar.length) {
|
||||
throw new TarExtractError('corrupt tar: entry content is truncated')
|
||||
}
|
||||
|
||||
const typeflag = tar[off + TYPEFLAG_OFFSET]
|
||||
const isRegular = typeflag === TYPE_REGULAR || typeflag === TYPE_LEGACY
|
||||
if (isRegular) {
|
||||
const name = readName(tar, off)
|
||||
if (basename(name) === targetBasename) {
|
||||
if (isUnsafeEntryName(name)) {
|
||||
throw new TarExtractError(`refusing unsafe tar entry name: ${JSON.stringify(name)}`)
|
||||
}
|
||||
return tar.slice(contentStart, contentEnd) // copy — never a view into the archive buffer
|
||||
}
|
||||
}
|
||||
|
||||
// Advance past this entry's content, padded up to the next 512-byte boundary.
|
||||
off = contentEnd + ((TAR_BLOCK - (size % TAR_BLOCK)) % TAR_BLOCK)
|
||||
}
|
||||
throw new TarExtractError(`no "${targetBasename}" file entry found in tar archive`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gunzip a verified frp `.tar.gz` and return the inner `frpc` binary's bytes. `gunzipSync` is used
|
||||
* on already-hash-verified input (the pin gate runs first), so there is no attacker-controlled
|
||||
* decompression-bomb surface here; a corrupt/non-gzip payload throws `TarExtractError`.
|
||||
*/
|
||||
export function extractFrpcBinary(archiveGz: Uint8Array): Uint8Array {
|
||||
return extractTarFileByBasename(gunzip(archiveGz), FRPC_BASENAME)
|
||||
}
|
||||
|
||||
function gunzip(archiveGz: Uint8Array): Uint8Array {
|
||||
try {
|
||||
return new Uint8Array(gunzipSync(archiveGz))
|
||||
} catch (err: unknown) {
|
||||
const detail = err instanceof Error ? err.message : 'not a gzip stream'
|
||||
throw new TarExtractError(`corrupt frpc archive: gunzip failed (${detail})`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user