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>
417 lines
15 KiB
TypeScript
417 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
||
import { createHash } from 'node:crypto'
|
||
import { gzipSync } from 'node:zlib'
|
||
import {
|
||
detectFrpcPlatform,
|
||
FRPC_PLATFORMS,
|
||
FRPC_RELEASES,
|
||
provisionFrpc,
|
||
type FrpcPlatform,
|
||
type FrpcReleaseRef,
|
||
type ProvisionFrpcDeps,
|
||
} from '../src/provision/frpcBinary.js'
|
||
import {
|
||
extractTarFileByBasename,
|
||
extractFrpcBinary,
|
||
TarExtractError,
|
||
} from '../src/provision/untar.js'
|
||
|
||
function sha256Hex(data: Uint8Array): string {
|
||
return createHash('sha256').update(data).digest('hex')
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// In-test tar/gzip fixture builder — hand-builds real USTAR blocks so the
|
||
// extractor is exercised against the SAME on-disk layout frp ships (dir entry
|
||
// + regular files), with zero network access.
|
||
// ---------------------------------------------------------------------------
|
||
const TAR_BLOCK = 512
|
||
const TYPE_FILE = '0'
|
||
const TYPE_DIR = '5'
|
||
|
||
interface TarEntrySpec {
|
||
readonly name: string
|
||
readonly content: Uint8Array
|
||
readonly typeflag?: string
|
||
}
|
||
|
||
function writeOctalField(block: Uint8Array, offset: number, len: number, value: number): void {
|
||
const s = value.toString(8).padStart(len - 1, '0')
|
||
block.set(new TextEncoder().encode(s), offset)
|
||
block[offset + len - 1] = 0 // NUL terminator
|
||
}
|
||
|
||
function tarHeader(name: string, size: number, typeflag: string): Uint8Array {
|
||
const block = new Uint8Array(TAR_BLOCK)
|
||
const enc = new TextEncoder()
|
||
block.set(enc.encode(name).subarray(0, 100), 0)
|
||
writeOctalField(block, 100, 8, 0o755) // mode
|
||
writeOctalField(block, 108, 8, 0) // uid
|
||
writeOctalField(block, 116, 8, 0) // gid
|
||
writeOctalField(block, 124, 12, size) // size
|
||
writeOctalField(block, 136, 12, 0) // mtime
|
||
block[156] = typeflag.charCodeAt(0)
|
||
block.set(enc.encode('ustar'), 257) // magic "ustar\0"
|
||
block[263] = 0x30 // version "00"
|
||
block[264] = 0x30
|
||
// checksum: fields spaces during compute, then written as 6 octal + NUL + space
|
||
for (let i = 148; i < 156; i++) block[i] = 0x20
|
||
let sum = 0
|
||
for (let i = 0; i < TAR_BLOCK; i++) sum += block[i] ?? 0
|
||
block.set(enc.encode(sum.toString(8).padStart(6, '0')), 148)
|
||
block[154] = 0
|
||
block[155] = 0x20
|
||
return block
|
||
}
|
||
|
||
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
|
||
const total = parts.reduce((n, p) => n + p.length, 0)
|
||
const out = new Uint8Array(total)
|
||
let off = 0
|
||
for (const p of parts) {
|
||
out.set(p, off)
|
||
off += p.length
|
||
}
|
||
return out
|
||
}
|
||
|
||
function makeTar(entries: readonly TarEntrySpec[]): Uint8Array {
|
||
const parts: Uint8Array[] = []
|
||
for (const e of entries) {
|
||
parts.push(tarHeader(e.name, e.content.length, e.typeflag ?? TYPE_FILE))
|
||
parts.push(e.content)
|
||
const pad = (TAR_BLOCK - (e.content.length % TAR_BLOCK)) % TAR_BLOCK
|
||
if (pad > 0) parts.push(new Uint8Array(pad))
|
||
}
|
||
parts.push(new Uint8Array(TAR_BLOCK * 2)) // end-of-archive: two zero blocks
|
||
return concatBytes(parts)
|
||
}
|
||
|
||
function makeTarGz(entries: readonly TarEntrySpec[]): Uint8Array {
|
||
return new Uint8Array(gzipSync(makeTar(entries)))
|
||
}
|
||
|
||
const FRPC_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0xde, 0xad, 0xbe, 0xef]) // fake "ELF" frpc
|
||
const FRPS_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x11, 0x22, 0x33]) // decoy frps
|
||
|
||
/** A realistic frp archive layout: dir entry + frpc + decoy frps + LICENSE. */
|
||
function realisticFrpTarGz(prefix = 'frp_0.61.1_darwin_arm64'): Uint8Array {
|
||
return makeTarGz([
|
||
{ name: `${prefix}/`, content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||
{ name: `${prefix}/frps`, content: FRPS_BYTES },
|
||
{ name: `${prefix}/frpc`, content: FRPC_BYTES },
|
||
{ name: `${prefix}/LICENSE`, content: new TextEncoder().encode('MIT') },
|
||
])
|
||
}
|
||
|
||
interface FakeFsState {
|
||
writes: Map<string, Uint8Array>
|
||
renames: Array<{ from: string; to: string }>
|
||
removed: string[]
|
||
chmods: Array<{ path: string; mode: number }>
|
||
mkdirs: string[]
|
||
}
|
||
|
||
function makeFakeDeps(bytesByUrl: Record<string, Uint8Array>): {
|
||
deps: ProvisionFrpcDeps
|
||
state: FakeFsState
|
||
fetchedUrls: string[]
|
||
} {
|
||
const state: FakeFsState = {
|
||
writes: new Map(),
|
||
renames: [],
|
||
removed: [],
|
||
chmods: [],
|
||
mkdirs: [],
|
||
}
|
||
const fetchedUrls: string[] = []
|
||
const deps: ProvisionFrpcDeps = {
|
||
fetch: async (url) => {
|
||
fetchedUrls.push(url)
|
||
const bytes = bytesByUrl[url]
|
||
if (!bytes) throw new Error(`test: no fixture bytes for ${url}`)
|
||
return bytes
|
||
},
|
||
fs: {
|
||
mkdir: async (dir) => {
|
||
state.mkdirs.push(dir)
|
||
},
|
||
writeFile: async (path, data) => {
|
||
state.writes.set(path, data)
|
||
},
|
||
rename: async (from, to) => {
|
||
state.renames.push({ from, to })
|
||
const data = state.writes.get(from)
|
||
if (data) {
|
||
state.writes.delete(from)
|
||
state.writes.set(to, data)
|
||
}
|
||
},
|
||
chmod: async (path, mode) => {
|
||
state.chmods.push({ path, mode })
|
||
},
|
||
rm: async (path) => {
|
||
state.removed.push(path)
|
||
state.writes.delete(path)
|
||
},
|
||
},
|
||
}
|
||
return { deps, state, fetchedUrls }
|
||
}
|
||
|
||
function releaseOverride(
|
||
platform: FrpcPlatform,
|
||
ref: FrpcReleaseRef,
|
||
): Record<FrpcPlatform, FrpcReleaseRef> {
|
||
return { ...FRPC_RELEASES, [platform]: ref }
|
||
}
|
||
|
||
const BIN_DIR = '/opt/wt/bin'
|
||
const BIN_PATH = '/opt/wt/bin/frpc'
|
||
|
||
describe('detectFrpcPlatform (B3)', () => {
|
||
it('maps darwin/linux × arm64/amd64 (node x64 → amd64)', () => {
|
||
expect(detectFrpcPlatform('darwin', 'arm64')).toBe('darwin-arm64')
|
||
expect(detectFrpcPlatform('darwin', 'x64')).toBe('darwin-amd64')
|
||
expect(detectFrpcPlatform('linux', 'arm64')).toBe('linux-arm64')
|
||
expect(detectFrpcPlatform('linux', 'x64')).toBe('linux-amd64')
|
||
})
|
||
|
||
it('returns null for unsupported platform/arch', () => {
|
||
expect(detectFrpcPlatform('win32', 'x64')).toBeNull()
|
||
expect(detectFrpcPlatform('linux', 'ia32')).toBeNull()
|
||
expect(detectFrpcPlatform('freebsd', 'arm64')).toBeNull()
|
||
expect(detectFrpcPlatform('darwin', 'mips')).toBeNull()
|
||
})
|
||
})
|
||
|
||
describe('FRPC_RELEASES pinning', () => {
|
||
it('pins a release per supported platform (https url, semver, 64-hex sha256)', () => {
|
||
expect([...FRPC_PLATFORMS].sort()).toEqual([
|
||
'darwin-amd64',
|
||
'darwin-arm64',
|
||
'linux-amd64',
|
||
'linux-arm64',
|
||
])
|
||
for (const platform of FRPC_PLATFORMS) {
|
||
const ref = FRPC_RELEASES[platform]
|
||
expect(ref.url.startsWith('https://')).toBe(true)
|
||
expect(ref.url.endsWith('.tar.gz')).toBe(true)
|
||
expect(ref.version).toMatch(/^\d+\.\d+\.\d+$/)
|
||
expect(ref.sha256).toMatch(/^[0-9a-f]{64}$/)
|
||
}
|
||
})
|
||
|
||
it('pins REAL (non-placeholder) frp v0.61.1 checksums', () => {
|
||
for (const platform of FRPC_PLATFORMS) {
|
||
expect(FRPC_RELEASES[platform].sha256).not.toBe('0'.repeat(64))
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('extractTarFileByBasename (tar path-traversal safe extractor)', () => {
|
||
it('returns the bytes of the first regular file whose basename matches', () => {
|
||
const tar = makeTar([
|
||
{ name: 'frp_x/', content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||
])
|
||
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
|
||
expect(extractTarFileByBasename(tar, 'frps')).toEqual(FRPS_BYTES)
|
||
})
|
||
|
||
it('does NOT match a directory entry that shares the basename', () => {
|
||
const tar = makeTar([
|
||
{ name: 'frpc/', content: new Uint8Array(0), typeflag: TYPE_DIR },
|
||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||
])
|
||
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
|
||
})
|
||
|
||
it('throws when no matching file entry exists', () => {
|
||
const tar = makeTar([{ name: 'frp_x/frps', content: FRPS_BYTES }])
|
||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(TarExtractError)
|
||
})
|
||
|
||
it('REJECTS a matching entry whose name contains a ".." traversal segment', () => {
|
||
const tar = makeTar([{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }])
|
||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|traversal|\.\./i)
|
||
})
|
||
|
||
it('REJECTS a matching entry with an absolute path name', () => {
|
||
const tar = makeTar([{ name: '/etc/frpc', content: FRPC_BYTES }])
|
||
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|absolute/i)
|
||
})
|
||
|
||
it('throws on a corrupt (truncated) tar rather than reading out of bounds', () => {
|
||
const full = makeTar([{ name: 'frp_x/frpc', content: FRPC_BYTES }])
|
||
const truncated = full.subarray(0, TAR_BLOCK + 4) // header + partial content
|
||
expect(() => extractTarFileByBasename(truncated, 'frpc')).toThrow(TarExtractError)
|
||
})
|
||
})
|
||
|
||
describe('extractFrpcBinary (gunzip + extract)', () => {
|
||
it('gunzips then extracts the inner frpc bytes', () => {
|
||
expect(extractFrpcBinary(realisticFrpTarGz())).toEqual(FRPC_BYTES)
|
||
})
|
||
|
||
it('throws on non-gzip input', () => {
|
||
expect(() => extractFrpcBinary(new Uint8Array([1, 2, 3, 4]))).toThrow(TarExtractError)
|
||
})
|
||
})
|
||
|
||
describe('provisionFrpc (B3 verify-download + extract discipline)', () => {
|
||
it('selects the arch URL, VERIFIES the archive, and places the INNER frpc (not the archive)', async () => {
|
||
const archive = realisticFrpTarGz()
|
||
const url = 'https://example.test/frp-darwin-arm64.tar.gz'
|
||
const releases = releaseOverride('darwin-arm64', {
|
||
version: '0.61.1',
|
||
url,
|
||
sha256: sha256Hex(archive),
|
||
})
|
||
const { deps, state, fetchedUrls } = makeFakeDeps({ [url]: archive })
|
||
|
||
const result = await provisionFrpc(
|
||
{ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases },
|
||
deps,
|
||
)
|
||
|
||
expect(fetchedUrls).toEqual([url])
|
||
expect(result.binPath).toBe(BIN_PATH)
|
||
expect(result.version).toBe('0.61.1')
|
||
expect(result.platform).toBe('darwin-arm64')
|
||
// atomic place: renamed into the final path, made executable
|
||
expect(state.renames.some((r) => r.to === BIN_PATH)).toBe(true)
|
||
expect(state.chmods.some((c) => (c.mode & 0o111) !== 0)).toBe(true)
|
||
// the placed file is the EXTRACTED frpc binary, NOT the .tar.gz archive
|
||
const placed = state.writes.get(BIN_PATH)
|
||
expect(placed).toEqual(FRPC_BYTES)
|
||
expect(placed).not.toEqual(archive)
|
||
})
|
||
|
||
it('ignores the decoy frps entry and places frpc even when frps precedes it', async () => {
|
||
const archive = makeTarGz([
|
||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||
{ name: 'frp_x/frpc', content: FRPC_BYTES },
|
||
])
|
||
const url = 'https://example.test/frp.tar.gz'
|
||
const releases = releaseOverride('linux-amd64', {
|
||
version: '0.61.1',
|
||
url,
|
||
sha256: sha256Hex(archive),
|
||
})
|
||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||
|
||
await provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps)
|
||
|
||
expect(state.writes.get(BIN_PATH)).toEqual(FRPC_BYTES)
|
||
})
|
||
|
||
it('REJECTS on sha256 mismatch and places NO binary (temp cleaned up, no extraction)', async () => {
|
||
const archive = realisticFrpTarGz()
|
||
const url = 'https://example.test/frp-linux-amd64.tar.gz'
|
||
const releases = releaseOverride('linux-amd64', {
|
||
version: '0.61.1',
|
||
url,
|
||
sha256: 'f'.repeat(64), // deliberately wrong
|
||
})
|
||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||
|
||
await expect(
|
||
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||
).rejects.toThrow(/sha-?256|hash|integrity|mismatch/i)
|
||
|
||
expect(state.renames).toEqual([])
|
||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||
expect(state.removed.length).toBeGreaterThan(0) // unverified temp removed
|
||
})
|
||
|
||
it('never places or execs before verifying (mismatch leaves nothing executable)', async () => {
|
||
const archive = realisticFrpTarGz()
|
||
const url = 'https://example.test/bad.tar.gz'
|
||
const releases = releaseOverride('linux-arm64', {
|
||
version: '0.61.1',
|
||
url,
|
||
sha256: '0'.repeat(64),
|
||
})
|
||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||
|
||
await expect(
|
||
provisionFrpc({ platform: 'linux', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
|
||
).rejects.toThrow()
|
||
|
||
expect(state.chmods.every((c) => c.path !== BIN_PATH)).toBe(true)
|
||
})
|
||
|
||
it('throws and places nothing when the verified archive has NO frpc entry', async () => {
|
||
const archive = makeTarGz([
|
||
{ name: 'frp_x/frps', content: FRPS_BYTES },
|
||
{ name: 'frp_x/LICENSE', content: new TextEncoder().encode('MIT') },
|
||
])
|
||
const url = 'https://example.test/no-frpc.tar.gz'
|
||
const releases = releaseOverride('darwin-amd64', {
|
||
version: '0.61.1',
|
||
url,
|
||
sha256: sha256Hex(archive),
|
||
})
|
||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||
|
||
await expect(
|
||
provisionFrpc({ platform: 'darwin', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||
).rejects.toThrow(/extract|frpc|tar/i)
|
||
|
||
expect(state.renames).toEqual([])
|
||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||
expect(state.removed.length).toBeGreaterThan(0) // temp removed on extraction failure
|
||
})
|
||
|
||
it('REJECTS a traversal frpc entry and never writes outside binDir', async () => {
|
||
const archive = makeTarGz([
|
||
{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES },
|
||
])
|
||
const url = 'https://example.test/evil.tar.gz'
|
||
const releases = releaseOverride('linux-amd64', {
|
||
version: '0.61.1',
|
||
url,
|
||
sha256: sha256Hex(archive),
|
||
})
|
||
const { deps, state } = makeFakeDeps({ [url]: archive })
|
||
|
||
await expect(
|
||
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
|
||
).rejects.toThrow()
|
||
|
||
// nothing placed, and every write that ever happened stayed inside binDir
|
||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||
expect(state.renames).toEqual([])
|
||
expect([...state.writes.keys()].every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
|
||
expect(state.removed.every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
|
||
})
|
||
|
||
it('REJECTS a hash-matching but corrupt (non-gzip) archive after the gate', async () => {
|
||
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) // hashes fine, not a gzip
|
||
const url = 'https://example.test/corrupt.tar.gz'
|
||
const releases = releaseOverride('darwin-arm64', {
|
||
version: '0.61.1',
|
||
url,
|
||
sha256: sha256Hex(garbage),
|
||
})
|
||
const { deps, state } = makeFakeDeps({ [url]: garbage })
|
||
|
||
await expect(
|
||
provisionFrpc({ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
|
||
).rejects.toThrow(/extract|gzip|tar/i)
|
||
|
||
expect(state.writes.has(BIN_PATH)).toBe(false)
|
||
expect(state.removed.length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('throws a clear error on an unsupported platform (nothing fetched)', async () => {
|
||
const { deps, fetchedUrls } = makeFakeDeps({})
|
||
await expect(
|
||
provisionFrpc({ platform: 'win32', arch: 'x64', binDir: BIN_DIR }, deps),
|
||
).rejects.toThrow(/unsupported|platform/i)
|
||
expect(fetchedUrls).toEqual([])
|
||
})
|
||
})
|