diff --git a/BUILD.md b/BUILD.md index b701a05..99490ce 100644 --- a/BUILD.md +++ b/BUILD.md @@ -53,7 +53,7 @@ adapted files to AGPL-3.0. | R2 | Client-side mTLS termination + metadata sanitization | | R3 | Embedded mode (in-process with `helm`) | | R4 | Remote reverse-tunnel mode (`helm` dials out) + reconnect/keepalive | -| R5 | Relay enrollment (bootstrap token, Fleet-CA relay cert) | +| R5 | Relay enrollment — `gen-csr` CSR-over-SSH, Fleet-CA relay cert (decision 14, no token) | | R6 | Static-binary packaging + deploy docs | ## Non-negotiables diff --git a/README.md b/README.md index 049e362..cfceb80 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,17 @@ Go · transparent gRPC proxy · reverse-tunnel transport (multiplexed) · mTLS. - [`relay/`](relay/) — the embeddable relay: the transparent proxy, the public mTLS listener, and the in-memory `Pipe` for embedded mode. - [`tunnel/`](tunnel/) — the reverse-tunnel transport `helm` dials out over. -- [`cmd/beacon`](cmd/beacon/) — the remote-relay binary (`beacon run`). +- [`cmd/beacon`](cmd/beacon/) — the relay binary: `gen-csr` (SSH enrolment) + and `run` (the remote relay). `helm` embeds a relay in-process by importing the `relay` package — see [docs/HELM-INTEGRATION.md](docs/HELM-INTEGRATION.md). ## Status -🚧 Pre-alpha. The transparent proxy and both transports (embedded + remote) -are built; relay enrollment and packaging are next. See [BUILD.md](BUILD.md). +🚧 Pre-alpha. The transparent proxy, both transports (embedded + remote), and +SSH relay enrolment (`gen-csr`) are built; static-binary packaging and deploy +docs are next. See [BUILD.md](BUILD.md). ## License diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index e43c852..4715cba 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -63,3 +63,25 @@ func TestLoadMaterialComplete(t *testing.T) { t.Errorf("relayCert = %q, want %q", m.relayCert, "pem-"+fileRelayCrt) } } + +// TestGenCSRCommand confirms `beacon gen-csr` writes the relay key into +// the config dir and prints only a CSR PEM to stdout — helm captures +// that stdout verbatim over SSH. +func TestGenCSRCommand(t *testing.T) { + dir := t.TempDir() + root := newRootCmd() + var out, errOut bytes.Buffer + root.SetOut(&out) + root.SetErr(&errOut) + root.SetArgs([]string{"gen-csr", "--config-dir", dir}) + + if err := root.Execute(); err != nil { + t.Fatalf("execute gen-csr: %v", err) + } + if got := out.String(); !strings.HasPrefix(got, "-----BEGIN CERTIFICATE REQUEST-----") { + t.Errorf("stdout is not a CSR PEM: %q", got) + } + if _, err := os.Stat(filepath.Join(dir, fileRelayKey)); err != nil { + t.Errorf("relay key not written: %v", err) + } +} diff --git a/internal/cli/gencsr.go b/internal/cli/gencsr.go new file mode 100644 index 0000000..1a84cc2 --- /dev/null +++ b/internal/cli/gencsr.go @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 The PharosVPN Authors + +package cli + +import ( + "fmt" + "path/filepath" + + "github.com/PharosVPN/beacon/internal/pki" + "github.com/spf13/cobra" +) + +// newGenCSRCmd generates the relay's mTLS keypair on the host and +// prints a PEM-encoded certificate signing request to stdout. +// +// This is the first step of SSH enrolment (DESIGN §5, decision 14): +// helm runs `beacon gen-csr` over SSH, captures the CSR from stdout, +// signs it with the Fleet CA, and pushes relay.crt, fleet-ca.crt and +// device-ca.crt back. The relay's private key is written to relay.key +// and never leaves the host. +// +// Re-running gen-csr is idempotent — an existing key is reused. +func newGenCSRCmd() *cobra.Command { + var configDir string + + cmd := &cobra.Command{ + Use: "gen-csr", + Short: "Generate the relay keypair and print a CSR for helm to sign", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + keyPath := filepath.Join(configDir, fileRelayKey) + res, err := pki.GenerateCSR(keyPath) + if err != nil { + return err + } + + // Diagnostics go to stderr; stdout carries only the CSR so + // helm can capture it cleanly over SSH. A failed diagnostic + // write must not fail gen-csr itself. + if res.KeyGenerated { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "beacon: generated relay key at %s\n", keyPath) + } else { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "beacon: reusing existing relay key at %s\n", keyPath) + } + _, err = cmd.OutOrStdout().Write(res.CSRPEM) + return err + }, + } + cmd.Flags().StringVar(&configDir, "config-dir", defaultConfigDir, + "directory the relay keypair is written to") + return cmd +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 3ae3b11..b7b339e 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,10 +1,11 @@ // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 The PharosVPN Authors -// Package cli wires up the beacon command-line interface — the `run` -// command that operates a remote relay, and `version`. The embedded -// relay is not a CLI command: helm imports github.com/PharosVPN/beacon/relay -// and runs it in-process (see docs/HELM-INTEGRATION.md). +// Package cli wires up the beacon command-line interface — `gen-csr` +// and `run`, which form the helm↔beacon relay-enrolment contract, plus +// `version`. The embedded relay is not a CLI command: helm imports +// github.com/PharosVPN/beacon/relay and runs it in-process (see +// docs/HELM-INTEGRATION.md). package cli import ( @@ -34,6 +35,7 @@ func newRootCmd() *cobra.Command { SilenceErrors: true, } root.AddCommand( + newGenCSRCmd(), newRunCmd(), newVersionCmd(), ) diff --git a/internal/pki/csr.go b/internal/pki/csr.go new file mode 100644 index 0000000..3ace078 --- /dev/null +++ b/internal/pki/csr.go @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 The PharosVPN Authors + +// Package pki handles beacon's relay-side certificate material: it +// generates the relay's mTLS keypair on the host and emits a +// certificate signing request. +// +// The relay's private key is generated here and never leaves the host. +// helm signs the CSR with the Fleet CA and pushes back the relay +// certificate and the two trust anchors over SSH (DESIGN §5, decision +// 14 — CSR-over-SSH, no bootstrap token). +// +// The CSR is deliberately plain: it carries only the public key. helm +// is the sole authority on the relay's identity and overrides the +// subject and SANs when it signs — Subject O="PharosVPN Relay" (the +// pinned delegation marker, which a relay host must not self-assert), +// dual ServerAuth+ClientAuth EKU, and the public-endpoint DNS SAN. See +// helm/BUILD.md, "Relay enrollment contract". +package pki + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" +) + +// keyFileMode is restrictive: the relay private key is readable only by +// its owner. It never leaves the host. +const keyFileMode = 0o600 + +// csrSubject is a human-readable label only. helm ignores the CSR +// subject entirely and assigns the real identity at signing time — +// crucially Organization, which carries the delegation marker and must +// not be self-asserted by the relay host. +var csrSubject = pkix.Name{CommonName: "pharos-beacon-relay"} + +// CSRResult is the outcome of GenerateCSR. +type CSRResult struct { + // CSRPEM is the PEM-encoded PKCS#10 certificate request, for helm + // to sign. + CSRPEM []byte + // KeyGenerated reports whether a new private key was created. It is + // false when an existing key at keyPath was reused. + KeyGenerated bool +} + +// GenerateCSR ensures a relay private key exists at keyPath and returns +// a certificate signing request built from it. +// +// If keyPath already holds a key it is reused, making `beacon gen-csr` +// idempotent: re-running it after a failed enrolment emits a fresh CSR +// for the same key rather than orphaning the old one. The parent +// directory is created if missing. +func GenerateCSR(keyPath string) (CSRResult, error) { + key, generated, err := loadOrCreateKey(keyPath) + if err != nil { + return CSRResult{}, err + } + + csrDER, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{ + Subject: csrSubject, + SignatureAlgorithm: x509.ECDSAWithSHA256, + }, key) + if err != nil { + return CSRResult{}, fmt.Errorf("pki: create CSR: %w", err) + } + return CSRResult{ + CSRPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER}), + KeyGenerated: generated, + }, nil +} + +// loadOrCreateKey returns the relay private key at keyPath, generating +// and persisting a new ECDSA P-256 key if none exists. +func loadOrCreateKey(keyPath string) (*ecdsa.PrivateKey, bool, error) { + switch existing, err := os.ReadFile(keyPath); { + case err == nil: + key, err := parseECKey(existing) + if err != nil { + return nil, false, fmt.Errorf("pki: existing key %s: %w", keyPath, err) + } + return key, false, nil + case !errors.Is(err, os.ErrNotExist): + return nil, false, fmt.Errorf("pki: read %s: %w", keyPath, err) + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, false, fmt.Errorf("pki: generate key: %w", err) + } + if err := writeKey(keyPath, key); err != nil { + return nil, false, err + } + return key, true, nil +} + +// writeKey persists key as a PKCS#8 PEM file with owner-only +// permissions. +func writeKey(keyPath string, key *ecdsa.PrivateKey) error { + if dir := filepath.Dir(keyPath); dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("pki: create %s: %w", dir, err) + } + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return fmt.Errorf("pki: marshal key: %w", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) + if err := os.WriteFile(keyPath, pemBytes, keyFileMode); err != nil { + return fmt.Errorf("pki: write %s: %w", keyPath, err) + } + return nil +} + +// parseECKey decodes a PKCS#8 PEM-encoded ECDSA private key. +func parseECKey(pemBytes []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("not a PEM block") + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + key, ok := parsed.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not an ECDSA key (%T)", parsed) + } + return key, nil +} diff --git a/internal/pki/csr_test.go b/internal/pki/csr_test.go new file mode 100644 index 0000000..14148c0 --- /dev/null +++ b/internal/pki/csr_test.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// Copyright (C) 2026 The PharosVPN Authors + +package pki + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "os" + "path/filepath" + "testing" +) + +// TestGenerateCSRFresh confirms a first run writes an owner-only key +// and emits a CSR whose public key matches it. +func TestGenerateCSRFresh(t *testing.T) { + keyPath := filepath.Join(t.TempDir(), "relay.key") + + res, err := GenerateCSR(keyPath) + if err != nil { + t.Fatalf("GenerateCSR: %v", err) + } + if !res.KeyGenerated { + t.Error("KeyGenerated = false on a fresh key") + } + + // The key file must exist and be readable only by its owner — the + // relay private key never leaves the host. + info, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("stat key: %v", err) + } + if perm := info.Mode().Perm(); perm != keyFileMode { + t.Errorf("key file mode = %o, want %o", perm, keyFileMode) + } + + csr := parseCSR(t, res.CSRPEM) + if err := csr.CheckSignature(); err != nil { + t.Errorf("CSR signature invalid: %v", err) + } + + // The CSR's public key must be the one on disk. + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read key: %v", err) + } + key, err := parseECKey(keyPEM) + if err != nil { + t.Fatalf("parse key: %v", err) + } + csrPub, ok := csr.PublicKey.(*ecdsa.PublicKey) + if !ok { + t.Fatalf("CSR public key is %T, want *ecdsa.PublicKey", csr.PublicKey) + } + if !csrPub.Equal(&key.PublicKey) { + t.Error("CSR public key does not match the on-disk key") + } +} + +// TestGenerateCSRIdempotent confirms a second run reuses the existing +// key — re-running gen-csr after a failed enrolment must not orphan it. +func TestGenerateCSRIdempotent(t *testing.T) { + keyPath := filepath.Join(t.TempDir(), "relay.key") + + if _, err := GenerateCSR(keyPath); err != nil { + t.Fatalf("first GenerateCSR: %v", err) + } + first, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("read key: %v", err) + } + + res, err := GenerateCSR(keyPath) + if err != nil { + t.Fatalf("second GenerateCSR: %v", err) + } + if res.KeyGenerated { + t.Error("KeyGenerated = true on the second run; key was not reused") + } + second, err := os.ReadFile(keyPath) + if err != nil { + t.Fatalf("re-read key: %v", err) + } + if string(first) != string(second) { + t.Error("key file changed across runs") + } +} + +// TestGenerateCSRPlainSubject confirms the CSR does not self-assert the +// delegation Organization — helm is the sole authority on relay +// identity and assigns it at signing time. +func TestGenerateCSRPlainSubject(t *testing.T) { + res, err := GenerateCSR(filepath.Join(t.TempDir(), "relay.key")) + if err != nil { + t.Fatalf("GenerateCSR: %v", err) + } + csr := parseCSR(t, res.CSRPEM) + if len(csr.Subject.Organization) != 0 { + t.Errorf("CSR carries Organization %v; it must not self-assert one", + csr.Subject.Organization) + } + if len(csr.DNSNames) != 0 { + t.Errorf("CSR carries SANs %v; helm sets the hostname", csr.DNSNames) + } +} + +// TestGenerateCSRRejectsBadKey confirms a corrupt key file surfaces a +// clear error instead of silently minting a new key. +func TestGenerateCSRRejectsBadKey(t *testing.T) { + keyPath := filepath.Join(t.TempDir(), "relay.key") + if err := os.WriteFile(keyPath, []byte("not a key"), keyFileMode); err != nil { + t.Fatalf("write bad key: %v", err) + } + if _, err := GenerateCSR(keyPath); err == nil { + t.Error("GenerateCSR accepted a corrupt key file") + } +} + +func parseCSR(t *testing.T, csrPEM []byte) *x509.CertificateRequest { + t.Helper() + block, _ := pem.Decode(csrPEM) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + t.Fatal("output is not a CERTIFICATE REQUEST PEM block") + } + csr, err := x509.ParseCertificateRequest(block.Bytes) + if err != nil { + t.Fatalf("parse CSR: %v", err) + } + return csr +}