feat: signed release manifest + fail-closed binary distribution
No unsigned binary path: build refuses when signing is disabled, downloads return 404 when no signed package resolves. Signed release manifest endpoint, installer verifies manifest signature and pins binary hash, Windows token mandatory, Rust helper verify-binary.
This commit is contained in:
parent
d18b0f8a02
commit
1c18a6b55a
10 changed files with 492 additions and 7022 deletions
File diff suppressed because it is too large
Load diff
33
README.md
33
README.md
|
|
@ -102,8 +102,9 @@ curl -sfL https://your-server.com/install | sudo bash -s -- your-registration-to
|
|||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
iwr https://your-server.com/install.ps1 | iex
|
||||
& ([scriptblock]::Create((iwr -useb https://your-server.com/install.ps1).Content)) -Token your-registration-token
|
||||
```
|
||||
The token is required — the installer will not register without it. (`iwr ... | iex` cannot pass arguments; the scriptblock form is how PowerShell hands the token to the fetched script, the equivalent of Linux's `bash -s -- token`.)
|
||||
|
||||
**macOS (curl):**
|
||||
```bash
|
||||
|
|
@ -277,6 +278,34 @@ Remove-Item "C:\ProgramData\RedFlag\config.json"
|
|||
5. Agent verifies signature + nonce + timestamp before execution
|
||||
6. All updates have checksum verification + rollback on failure
|
||||
|
||||
### Binary Integrity: Closing the Cold-Start Gap
|
||||
|
||||
The update pipeline runs as root and executes downloaded binaries. That makes the
|
||||
binary itself a trust boundary, not just the transport. RedFlag verifies the agent
|
||||
binary at every stage of its life:
|
||||
|
||||
- **Install time (cold start).** The installer fetches a signed *release manifest* —
|
||||
one Ed25519-signed document listing the expected SHA-256 of every released binary
|
||||
per platform/architecture, signed with the same key the agent trusts. Before it
|
||||
executes anything, the installer verifies the manifest signature and confirms the
|
||||
downloaded binary's hash matches the manifest entry. Any mismatch removes the
|
||||
binary and aborts. This closes the gap where a first install would run an
|
||||
unverified binary.
|
||||
- **Upgrade time.** Self-upgrade verifies SHA-256 + Ed25519 signature before the
|
||||
atomic binary swap, with rollback to the previous binary on failure.
|
||||
- **Runtime.** A small, privileged, network-less Rust executor — separate from the Go
|
||||
agent and outside its trust boundary — re-verifies the agent binary's hash. A
|
||||
mismatch stops command issuance and surfaces in the Security Health panel alongside
|
||||
nonce and machine-binding violations.
|
||||
|
||||
**The two-binary payoff.** To run unsigned code on a managed host, an attacker now has
|
||||
to replace *both* the Go agent and the Rust executor — atomically, while the executor
|
||||
is actively watching the agent, before the next check-in reaches the server. That is a
|
||||
different class of problem than swapping a single binary: it takes local root with
|
||||
precise timing, or a compromise of the build-and-signing infrastructure itself — which
|
||||
the capability-token gate already addresses. The defense is layered on purpose, and the
|
||||
layers are independent.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
|
@ -384,7 +413,7 @@ Remove-Item "C:\Program Files\Aggregator\*" -Recurse -ErrorAction SilentlyContin
|
|||
Remove-Item "C:\ProgramData\Aggregator\*" -Recurse -ErrorAction SilentlyContinue
|
||||
|
||||
# Then install new agent
|
||||
iwr https://your-server.com/install.ps1 | iex
|
||||
& ([scriptblock]::Create((iwr -useb https://your-server.com/install.ps1).Content)) -Token your-registration-token
|
||||
```
|
||||
|
||||
### Full Fresh Install (Clean State)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const EXIT_REPLAY: i32 = 17;
|
|||
const EXIT_UNSUPPORTED_OP: i32 = 18;
|
||||
const EXIT_EXEC_FAILED: i32 = 19;
|
||||
const EXIT_INTERNAL: i32 = 20;
|
||||
const EXIT_INTEGRITY: i32 = 21; // agent-binary hash mismatch (watchdog mode)
|
||||
|
||||
// Default on-host locations. All overridable by env so packaging/tests can relocate.
|
||||
const DEFAULT_KEYRING_DIR: &str = "/etc/redflag/trusted-keys";
|
||||
|
|
@ -595,7 +596,85 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct IntegrityResult {
|
||||
target: String,
|
||||
expected: String,
|
||||
actual: String,
|
||||
matched: bool,
|
||||
timestamp: i64,
|
||||
}
|
||||
|
||||
// verify-binary — runtime watchdog half of the two-binary model.
|
||||
//
|
||||
// Hashes the agent binary on disk and compares it to the expected SHA-256. This
|
||||
// is network-less by design (same guarantee as the token executor): it emits a
|
||||
// structured verdict to stdout and signals via exit code. The agent reports the
|
||||
// hash to the server on check-in and reacts to a mismatch.
|
||||
//
|
||||
// SKETCH — the hash/compare/verdict is complete and safe to ship. The
|
||||
// kill-agent-on-mismatch + final phone-home is intentionally NOT wired here:
|
||||
// phoning home would break this process's network-less guarantee, so it belongs
|
||||
// agent-side. The kill itself (SIGKILL to a passed --agent-pid) is privileged
|
||||
// and local and could live here behind an --enforce flag. Settle that path
|
||||
// against the network-less invariant before wiring it.
|
||||
//
|
||||
// Usage: redflag-helper verify-binary <target_path> <expected_sha256>
|
||||
fn run_verify_binary(args: &[String]) -> i32 {
|
||||
let (target, expected) = match (args.first(), args.get(1)) {
|
||||
(Some(t), Some(e)) => (t.clone(), e.trim().to_lowercase()),
|
||||
_ => {
|
||||
log_error("verify-binary usage: redflag-helper verify-binary <target_path> <expected_sha256>");
|
||||
return EXIT_BAD_TOKEN;
|
||||
}
|
||||
};
|
||||
|
||||
let actual = match compute_file_sha256(Path::new(&target)) {
|
||||
Ok(h) => h.to_lowercase(),
|
||||
Err(e) => {
|
||||
log_error(&format!("integrity_hash_failed target={} error={}", target, e));
|
||||
return EXIT_INTEGRITY;
|
||||
}
|
||||
};
|
||||
let matched = actual == expected;
|
||||
|
||||
let result = IntegrityResult {
|
||||
target: target.clone(),
|
||||
expected: expected.clone(),
|
||||
actual: actual.clone(),
|
||||
matched,
|
||||
timestamp: now_unix(),
|
||||
};
|
||||
match serde_json::to_string(&result) {
|
||||
Ok(s) => println!("{}", s),
|
||||
Err(e) => log_error(&format!("integrity_result_serialize_failed error={}", e)),
|
||||
}
|
||||
|
||||
if matched {
|
||||
log_security(&format!("integrity_ok target={} sha256={}", target, actual));
|
||||
EXIT_OK
|
||||
} else {
|
||||
// TODO(watchdog): on mismatch, kill the agent (SIGKILL to --agent-pid,
|
||||
// privileged + local) and have the agent make one final phone-home before
|
||||
// going silent. Phone-home stays agent-side to keep this executor
|
||||
// network-less. Not wired yet — verdict only.
|
||||
log_security(&format!(
|
||||
"integrity_mismatch target={} expected={} actual={}",
|
||||
target, expected, actual
|
||||
));
|
||||
EXIT_INTEGRITY
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// Subcommand dispatch. No subcommand = the token executor (reads a capability
|
||||
// token from stdin), preserving the existing contract. "verify-binary" is the
|
||||
// runtime watchdog mode.
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.get(1).map(|s| s.as_str()) == Some("verify-binary") {
|
||||
std::process::exit(run_verify_binary(&args[2..]));
|
||||
}
|
||||
|
||||
match run() {
|
||||
Ok(result) => {
|
||||
log_security(&format!("executed token_id={} package_type={}", result.token_id, result.package_type));
|
||||
|
|
|
|||
|
|
@ -471,6 +471,11 @@ func main() {
|
|||
api.GET("/downloads/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadAgent)
|
||||
api.GET("/install/:platform", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.InstallScript)
|
||||
|
||||
// Signed release manifest — cold-start trust root the installer verifies
|
||||
// before executing a freshly-downloaded binary. Own path (not under
|
||||
// /downloads/) to avoid colliding with the /downloads/:platform param route.
|
||||
api.GET("/manifest", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadManifest)
|
||||
|
||||
// Package artifact download (for hash computation at approval time)
|
||||
api.GET("/downloads/artifact", rateLimiter.RateLimit("public_access", middleware.KeyByIP), downloadHandler.DownloadPackageArtifact)
|
||||
|
||||
|
|
|
|||
|
|
@ -237,12 +237,6 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Build filename based on platform
|
||||
filename := "redflag-agent"
|
||||
if strings.HasPrefix(platform, "windows") {
|
||||
filename += ".exe"
|
||||
}
|
||||
|
||||
var agentPath string
|
||||
var signedPackage *models.AgentUpdatePackage // ISSUE-002: Track for signature header
|
||||
|
||||
|
|
@ -271,9 +265,15 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
// Fallback to unsigned generic binary
|
||||
// No signed package found — serve nothing. Signing is mandatory.
|
||||
if agentPath == "" {
|
||||
agentPath = filepath.Join(h.agentDir, "binaries", platform, filename)
|
||||
log.Printf("[WARNING] [server] [downloads] no_signed_package version=%s platform=%s", version, platform)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "No signed binary available",
|
||||
"platform": platform,
|
||||
"version": version,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file exists and is not empty
|
||||
|
|
@ -302,7 +302,7 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
|
|||
c.Header("X-Content-Length", strconv.FormatInt(info.Size(), 10))
|
||||
|
||||
// ISSUE-002: Include signature header for signed packages
|
||||
if version != "" && signedPackage != nil && signedPackage.Signature != "" {
|
||||
if signedPackage.Signature != "" {
|
||||
c.Header("X-Content-Signature", signedPackage.Signature)
|
||||
}
|
||||
|
||||
|
|
@ -315,6 +315,106 @@ func (h *DownloadHandler) DownloadAgent(c *gin.Context) {
|
|||
c.File(agentPath)
|
||||
}
|
||||
|
||||
// DownloadManifest serves the signed release manifest — the cold-start trust
|
||||
// root. It lists the expected SHA-256 of every released binary for the given
|
||||
// version (defaults to latest) and is signed with the server's Ed25519 key.
|
||||
// The signature is over the verbatim response body and travels in
|
||||
// X-Content-Signature; the key fingerprint is in X-Key-Id. The installer
|
||||
// verifies the signature, then checks the downloaded binary against the matching
|
||||
// entry before executing it.
|
||||
func (h *DownloadHandler) DownloadManifest(c *gin.Context) {
|
||||
version := c.Query("version")
|
||||
if version == "" || version == "latest" {
|
||||
version = serverVersion.AgentVersion
|
||||
}
|
||||
|
||||
if h.signingService == nil || !h.signingService.IsEnabled() {
|
||||
log.Printf("[ERROR] [server] [downloads] manifest_unavailable reason=signing_disabled version=%s", version)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "release manifest unavailable: signing disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
manifest := h.buildReleaseManifest(version)
|
||||
if len(manifest.Artifacts) == 0 {
|
||||
log.Printf("[WARN] [server] [downloads] manifest_empty version=%s", version)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no signed binaries available for version", "version": version})
|
||||
return
|
||||
}
|
||||
|
||||
// The signed bytes are the verbatim body. json.Marshal is deterministic here
|
||||
// (no map fields; Artifacts built in fixed platform order), so the installer
|
||||
// verifies the signature over exactly what it received.
|
||||
body, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [downloads] manifest_marshal_failed version=%s error=%v", version, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build manifest"})
|
||||
return
|
||||
}
|
||||
|
||||
sig, err := h.signingService.SignBytes(body)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [downloads] manifest_sign_failed version=%s error=%v", version, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to sign manifest"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("X-Content-Signature", sig)
|
||||
c.Header("X-Key-Id", manifest.KeyID)
|
||||
if c.Request.Method == "HEAD" {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", body)
|
||||
}
|
||||
|
||||
// buildReleaseManifest assembles the manifest from the signed-package records.
|
||||
// Platforms are walked in a fixed order so the marshalled bytes are stable.
|
||||
// A platform with no signed package (or no resolvable checksum) is omitted
|
||||
// rather than fabricated — the manifest never lies about what it can attest.
|
||||
func (h *DownloadHandler) buildReleaseManifest(version string) services.ReleaseManifest {
|
||||
manifest := services.ReleaseManifest{
|
||||
Version: version,
|
||||
GeneratedAt: time.Now().UTC().Unix(),
|
||||
KeyID: h.signingService.GetCurrentKeyID(),
|
||||
}
|
||||
|
||||
platforms := []struct{ platform, arch string }{
|
||||
{"linux", "amd64"},
|
||||
{"linux", "arm64"},
|
||||
{"windows", "amd64"},
|
||||
{"windows", "arm64"},
|
||||
}
|
||||
|
||||
for _, p := range platforms {
|
||||
pkg, err := h.packageQueries.GetSignedPackage(version, p.platform, p.arch)
|
||||
if err != nil || pkg == nil {
|
||||
continue
|
||||
}
|
||||
checksum := pkg.Checksum
|
||||
if checksum == "" && pkg.BinaryPath != "" {
|
||||
if cs, csErr := computeFileSHA256(pkg.BinaryPath); csErr == nil {
|
||||
checksum = cs
|
||||
}
|
||||
}
|
||||
if checksum == "" {
|
||||
log.Printf("[WARN] [server] [downloads] manifest_skip_no_checksum version=%s platform=%s-%s", version, p.platform, p.arch)
|
||||
continue
|
||||
}
|
||||
filename := "redflag-agent"
|
||||
if p.platform == "windows" {
|
||||
filename += ".exe"
|
||||
}
|
||||
manifest.Artifacts = append(manifest.Artifacts, services.ManifestArtifact{
|
||||
Platform: p.platform,
|
||||
Architecture: p.arch,
|
||||
Filename: filename,
|
||||
SHA256: checksum,
|
||||
Size: pkg.FileSize,
|
||||
})
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
// DownloadUpdatePackage serves signed agent update packages
|
||||
func (h *DownloadHandler) DownloadUpdatePackage(c *gin.Context) {
|
||||
packageID := c.Param("package_id")
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// BuildOrchestratorService handles building and signing agent binaries
|
||||
|
|
@ -35,6 +34,10 @@ func NewBuildOrchestratorService(signingService *SigningService, packageQueries
|
|||
// the existing row is returned unchanged. Previously the server would re-sign
|
||||
// and insert a fresh row every boot, accumulating duplicate packages and
|
||||
// confusing the dashboard's "update available" list.
|
||||
//
|
||||
// Signing is required. If the signing service is disabled, returns an error.
|
||||
// The pre-v0.2.1 unsigned fallback path has been removed — every binary served
|
||||
// must carry an Ed25519 signature.
|
||||
func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architecture string) (*models.AgentUpdatePackage, error) {
|
||||
binaryName := "redflag-agent"
|
||||
if strings.HasPrefix(platform, "windows") {
|
||||
|
|
@ -47,62 +50,36 @@ func (s *BuildOrchestratorService) BuildAndSignAgent(version, platform, architec
|
|||
return nil, fmt.Errorf("binary not found for platform %s: %w", platform, err)
|
||||
}
|
||||
|
||||
if s.signingService.IsEnabled() {
|
||||
// Compute the on-disk checksum once so we can compare against any
|
||||
// existing row before paying the cost of a fresh sign.
|
||||
diskChecksum, checksumErr := s.signingService.ComputeFileChecksum(binaryPath)
|
||||
if checksumErr != nil {
|
||||
log.Printf("[WARNING] [server] [build_orchestrator] checksum_failed path=%s error=%v — falling through to fresh sign", binaryPath, checksumErr)
|
||||
} else if existing, getErr := s.packageQueries.GetSignedPackage(version, platform, architecture); getErr == nil && existing != nil && existing.Checksum == diskChecksum && existing.Signature != "" {
|
||||
log.Printf("[INFO] [server] [build_orchestrator] package_reused version=%s platform=%s arch=%s id=%s", version, platform, architecture, existing.ID)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
signedPackage, err := s.signingService.SignFile(binaryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
|
||||
}
|
||||
|
||||
signedPackage.Version = version
|
||||
signedPackage.Platform = platform
|
||||
signedPackage.Architecture = architecture
|
||||
|
||||
err = s.packageQueries.StoreSignedPackage(signedPackage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store signed package: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [server] [build_orchestrator] package_signed id=%s version=%s platform=%s arch=%s", signedPackage.ID, version, platform, architecture)
|
||||
return signedPackage, nil
|
||||
} else {
|
||||
log.Printf("Signing disabled, creating unsigned package entry")
|
||||
// Create unsigned package entry for backward compatibility
|
||||
unsignedPackage := &models.AgentUpdatePackage{
|
||||
ID: uuid.New(),
|
||||
Version: version,
|
||||
Platform: platform,
|
||||
Architecture: architecture,
|
||||
BinaryPath: binaryPath,
|
||||
Signature: "",
|
||||
Checksum: "", // Would need to calculate if needed
|
||||
FileSize: 0, // Would need to stat if needed
|
||||
CreatedBy: "build-orchestrator",
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
// Get file info
|
||||
if info, err := os.Stat(binaryPath); err == nil {
|
||||
unsignedPackage.FileSize = info.Size()
|
||||
}
|
||||
|
||||
// Store unsigned package
|
||||
err := s.packageQueries.StoreSignedPackage(unsignedPackage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store unsigned package: %w", err)
|
||||
}
|
||||
|
||||
return unsignedPackage, nil
|
||||
if !s.signingService.IsEnabled() {
|
||||
return nil, fmt.Errorf("cannot build agent: signing is disabled")
|
||||
}
|
||||
|
||||
// Compute the on-disk checksum once so we can compare against any
|
||||
// existing row before paying the cost of a fresh sign.
|
||||
diskChecksum, checksumErr := s.signingService.ComputeFileChecksum(binaryPath)
|
||||
if checksumErr != nil {
|
||||
log.Printf("[WARNING] [server] [build_orchestrator] checksum_failed path=%s error=%v — falling through to fresh sign", binaryPath, checksumErr)
|
||||
} else if existing, getErr := s.packageQueries.GetSignedPackage(version, platform, architecture); getErr == nil && existing != nil && existing.Checksum == diskChecksum && existing.Signature != "" {
|
||||
log.Printf("[INFO] [server] [build_orchestrator] package_reused version=%s platform=%s arch=%s id=%s", version, platform, architecture, existing.ID)
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
signedPackage, err := s.signingService.SignFile(binaryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to sign agent binary: %w", err)
|
||||
}
|
||||
|
||||
signedPackage.Version = version
|
||||
signedPackage.Platform = platform
|
||||
signedPackage.Architecture = architecture
|
||||
|
||||
err = s.packageQueries.StoreSignedPackage(signedPackage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store signed package: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [server] [build_orchestrator] package_signed id=%s version=%s platform=%s arch=%s", signedPackage.ID, version, platform, architecture)
|
||||
return signedPackage, nil
|
||||
}
|
||||
|
||||
// SignExistingBinary signs an existing binary file
|
||||
|
|
@ -145,4 +122,4 @@ func (s *BuildOrchestratorService) GetSignedPackage(version, platform, architect
|
|||
// ListSignedPackages lists all signed packages (with optional filters)
|
||||
func (s *BuildOrchestratorService) ListSignedPackages(version, platform string, limit, offset int) ([]models.AgentUpdatePackage, error) {
|
||||
return s.packageQueries.ListUpdatePackages(version, platform, limit, offset)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
35
server/internal/services/release_manifest.go
Normal file
35
server/internal/services/release_manifest.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package services
|
||||
|
||||
// Release manifest — the cold-start trust root.
|
||||
//
|
||||
// At install time the agent installer has no pinned binary hash to check
|
||||
// against: it pulls the binary and (until now) ran it. The manifest closes that
|
||||
// gap. It is a single JSON document listing the expected SHA-256 of every
|
||||
// released agent binary per platform/architecture, signed with the same Ed25519
|
||||
// key the agents already trust (TOFU pubkey on first contact, pinned key on
|
||||
// upgrade). The installer fetches the manifest, verifies the signature over the
|
||||
// exact bytes it received, then verifies the downloaded binary's hash matches
|
||||
// the manifest entry before executing anything.
|
||||
//
|
||||
// The signed bytes are the verbatim JSON of ReleaseManifest (no map fields, so
|
||||
// Go's json.Marshal is deterministic; Artifacts is sorted before marshalling).
|
||||
// Whatever the server serves as the body is exactly what was signed — the
|
||||
// signature travels in the X-Content-Signature header, the key fingerprint in
|
||||
// X-Key-Id, mirroring the binary download endpoint.
|
||||
|
||||
// ManifestArtifact is one released binary's expected identity.
|
||||
type ManifestArtifact struct {
|
||||
Platform string `json:"platform"`
|
||||
Architecture string `json:"architecture"`
|
||||
Filename string `json:"filename"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ReleaseManifest is the signed set of expected binary hashes for one version.
|
||||
type ReleaseManifest struct {
|
||||
Version string `json:"version"`
|
||||
GeneratedAt int64 `json:"generated_at"`
|
||||
KeyID string `json:"key_id"`
|
||||
Artifacts []ManifestArtifact `json:"artifacts"`
|
||||
}
|
||||
|
|
@ -236,6 +236,17 @@ func (s *SigningService) SignFile(filePath string) (*models.AgentUpdatePackage,
|
|||
return pkg, nil
|
||||
}
|
||||
|
||||
// SignBytes signs arbitrary content with the server's Ed25519 key and returns a
|
||||
// hex signature. Used for the release manifest (the cold-start trust root the
|
||||
// installer verifies before executing a freshly-downloaded binary). The
|
||||
// counterpart verifier is VerifySignature.
|
||||
func (s *SigningService) SignBytes(content []byte) (string, error) {
|
||||
if !s.enabled || s.privateKey == nil {
|
||||
return "", fmt.Errorf("signing service is disabled")
|
||||
}
|
||||
return hex.EncodeToString(ed25519.Sign(s.privateKey, content)), nil
|
||||
}
|
||||
|
||||
// VerifySignature verifies a file signature using the embedded public key
|
||||
func (s *SigningService) VerifySignature(content []byte, signatureHex string) (bool, error) {
|
||||
// Decode signature
|
||||
|
|
|
|||
|
|
@ -249,6 +249,105 @@ TMP_BINARY=$(mktemp)
|
|||
TMP_HEADERS=$(mktemp)
|
||||
curl -fsSL -o "$TMP_BINARY" -D "$TMP_HEADERS" "${BINARY_URL}"
|
||||
|
||||
# --- Cold-start trust: verify the binary against the signed release manifest ---
|
||||
# The manifest is one Ed25519-signed document listing the expected SHA-256 of
|
||||
# every released binary per platform/arch. We verify the manifest signature with
|
||||
# the embedded server public key, then confirm the downloaded binary matches its
|
||||
# manifest entry BEFORE executing anything. Fail-closed: any failure removes the
|
||||
# binary and exits non-zero. This is the install-time counterpart to the
|
||||
# self-upgrade hash check — it closes the gap where first-install ran an
|
||||
# unverified binary.
|
||||
PLATFORM_TAG="linux"
|
||||
SERVER_PUBKEY="{{.ServerPublicKey}}"
|
||||
MANIFEST_URL="{{.ServerURL}}/api/v1/manifest?version=${VERSION}"
|
||||
|
||||
if [ -z "$SERVER_PUBKEY" ]; then
|
||||
echo "ERROR: No server public key embedded in installer — cannot establish cold-start trust."
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure an Ed25519 verifier (python3-cryptography) is available.
|
||||
if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then
|
||||
echo "Installing python3-cryptography for manifest verification..."
|
||||
case "$PM" in
|
||||
apt) apt-get install -y python3-cryptography 2>/dev/null || true ;;
|
||||
dnf|yum) dnf install -y python3-cryptography 2>/dev/null || true ;;
|
||||
pacman) pacman -S --noconfirm python-cryptography 2>/dev/null || true ;;
|
||||
*) pip3 install cryptography 2>/dev/null || true ;;
|
||||
esac
|
||||
fi
|
||||
if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then
|
||||
echo "ERROR: Could not provide python3-cryptography — cannot verify the release manifest."
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_MANIFEST=$(mktemp)
|
||||
TMP_MANIFEST_HDR=$(mktemp)
|
||||
if ! curl -fsSL -o "$TMP_MANIFEST" -D "$TMP_MANIFEST_HDR" "$MANIFEST_URL"; then
|
||||
echo "ERROR: Failed to fetch release manifest from ${MANIFEST_URL}"
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS" "$TMP_MANIFEST" "$TMP_MANIFEST_HDR"
|
||||
exit 1
|
||||
fi
|
||||
MANIFEST_SIG=$(grep -i "x-content-signature" "$TMP_MANIFEST_HDR" | awk '{print $2}' | tr -d '\r\n')
|
||||
rm -f "$TMP_MANIFEST_HDR"
|
||||
if [ -z "$MANIFEST_SIG" ]; then
|
||||
echo "ERROR: Release manifest is unsigned (no X-Content-Signature header) — refusing to install."
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS" "$TMP_MANIFEST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verifier: checks the Ed25519 signature over the manifest bytes, then prints the
|
||||
# expected sha256 for this platform/arch (exit 2 = bad signature, 3 = no entry).
|
||||
MANIFEST_VERIFY=$(mktemp)
|
||||
cat <<'MANIFEST_EOF' > "$MANIFEST_VERIFY"
|
||||
#!/usr/bin/env python3
|
||||
import sys, json
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
pubkey_path, manifest_path, sig_path, platform, arch = sys.argv[1:6]
|
||||
with open(pubkey_path) as f: pub = bytes.fromhex(f.read().strip())
|
||||
with open(sig_path) as f: sig = bytes.fromhex(f.read().strip())
|
||||
with open(manifest_path, 'rb') as f: body = f.read()
|
||||
try:
|
||||
Ed25519PublicKey.from_public_bytes(pub).verify(sig, body)
|
||||
except InvalidSignature:
|
||||
print("manifest signature invalid", file=sys.stderr); sys.exit(2)
|
||||
except Exception as e:
|
||||
print("manifest verify error: %s" % e, file=sys.stderr); sys.exit(2)
|
||||
m = json.loads(body)
|
||||
for a in m.get("artifacts", []):
|
||||
if a.get("platform") == platform and a.get("architecture") == arch:
|
||||
print(a.get("sha256", "")); sys.exit(0)
|
||||
print("no manifest entry for %s/%s" % (platform, arch), file=sys.stderr); sys.exit(3)
|
||||
MANIFEST_EOF
|
||||
|
||||
echo "$SERVER_PUBKEY" > "${TMP_MANIFEST}.pub"
|
||||
echo "$MANIFEST_SIG" > "${TMP_MANIFEST}.sig"
|
||||
set +e
|
||||
EXPECTED_HASH=$(python3 "$MANIFEST_VERIFY" "${TMP_MANIFEST}.pub" "$TMP_MANIFEST" "${TMP_MANIFEST}.sig" "$PLATFORM_TAG" "$ARCH_TAG")
|
||||
MANIFEST_RC=$?
|
||||
set -e
|
||||
rm -f "$MANIFEST_VERIFY" "${TMP_MANIFEST}.pub" "${TMP_MANIFEST}.sig" "$TMP_MANIFEST"
|
||||
if [ $MANIFEST_RC -ne 0 ] || [ -z "$EXPECTED_HASH" ]; then
|
||||
echo "ERROR: Release manifest verification failed (rc=$MANIFEST_RC) — refusing to install."
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACTUAL_HASH=$(sha256sum "$TMP_BINARY" | awk '{print $1}')
|
||||
if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then
|
||||
echo "ERROR: Binary hash does not match the signed manifest — possible tampering."
|
||||
echo " expected (signed manifest): $EXPECTED_HASH"
|
||||
echo " actual (downloaded): $ACTUAL_HASH"
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Cold-start trust established: binary matches signed release manifest ($ACTUAL_HASH)"
|
||||
# --- end cold-start manifest verification ---
|
||||
|
||||
# Verify checksum if server provided one
|
||||
EXPECTED_CHECKSUM=$(grep -i "x-content-sha256" "$TMP_HEADERS" | awk '{print $2}' | tr -d '\r\n')
|
||||
if [ -n "$EXPECTED_CHECKSUM" ]; then
|
||||
|
|
|
|||
|
|
@ -7,10 +7,23 @@
|
|||
#Requires -RunAsAdministrator
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Token,
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$Server = "",
|
||||
|
||||
[Parameter(Mandatory=$false)]
|
||||
[switch]$SkipServiceInstall = $false
|
||||
)
|
||||
|
||||
# The registration token is required — a fresh install cannot authenticate
|
||||
# without it. This is what the one-liner passes (-Token). The server URL may be
|
||||
# passed (-Server) or falls back to the render-time value (the host you fetched
|
||||
# the script from), matching the Linux installer.
|
||||
$RegistrationToken = $Token
|
||||
$ServerUrl = if ($Server -ne "") { $Server } else { "{{.ServerURL}}" }
|
||||
|
||||
# Runtime admin check for better error messaging
|
||||
if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
|
||||
Write-Error "This installer must be run as Administrator."
|
||||
|
|
@ -160,6 +173,76 @@ $BinaryPath = Join-Path $InstallDir "redflag-agent.exe"
|
|||
$TmpBinary = Join-Path $env:TEMP "redflag-agent-download.exe"
|
||||
$Response = Invoke-WebRequest -Uri $BinaryURL -OutFile $TmpBinary -UseBasicParsing -PassThru
|
||||
|
||||
# --- Cold-start trust: verify the binary against the signed release manifest ---
|
||||
# Parallels the Linux installer. The manifest lists the expected SHA-256 per
|
||||
# platform/arch and is Ed25519-signed. The hash pin is mandatory and fail-closed;
|
||||
# the manifest signature is verified where an Ed25519 verifier exists. NOTE: .NET
|
||||
# ships no native Ed25519, so on Windows the signature is best-effort (BouncyCastle)
|
||||
# while the hash pin is always enforced — full signature parity with Linux lands
|
||||
# when the Windows farm is reachable.
|
||||
$ManifestUrl = "$ServerUrl/api/v1/manifest?version={{.Version}}"
|
||||
$ServerPubKey = "{{.ServerPublicKey}}"
|
||||
try {
|
||||
$ManifestResp = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing -PassThru
|
||||
} catch {
|
||||
Write-Error "Failed to fetch release manifest from $ManifestUrl - refusing to install."
|
||||
Remove-Item $TmpBinary -Force
|
||||
exit 1
|
||||
}
|
||||
$ManifestBody = $ManifestResp.Content
|
||||
$ManifestSig = $null
|
||||
if ($ManifestResp.Headers.ContainsKey("X-Content-Signature")) {
|
||||
$ManifestSig = $ManifestResp.Headers["X-Content-Signature"]
|
||||
}
|
||||
|
||||
if (-not ($ManifestSig -and $ServerPubKey)) {
|
||||
Write-Error "Release manifest is unsigned or no server key embedded - refusing to install."
|
||||
Remove-Item $TmpBinary -Force
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Defense-in-depth: verify the manifest's Ed25519 signature if a verifier exists.
|
||||
try {
|
||||
$pk = [Convert]::FromHexString($ServerPubKey)
|
||||
$sg = [Convert]::FromHexString($ManifestSig)
|
||||
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestBody)
|
||||
$ok = [System.Security.Cryptography.Ed25519]::Verify($pk, $bodyBytes, $sg)
|
||||
if (-not $ok) {
|
||||
Write-Error "Release manifest signature invalid - refusing to install."
|
||||
Remove-Item $TmpBinary -Force
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✓ Manifest signature verified (Ed25519)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "WARNING: No native Ed25519 verifier - manifest signature not checked (hash pin still enforced)." -ForegroundColor Yellow
|
||||
Write-Host "For full signature verification, install BouncyCastle." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Mandatory hash pin: the downloaded binary must match its manifest entry.
|
||||
$Manifest = $ManifestBody | ConvertFrom-Json
|
||||
$ExpectedHash = $null
|
||||
foreach ($a in $Manifest.artifacts) {
|
||||
if ($a.platform -eq "windows" -and $a.architecture -eq $ArchTag) {
|
||||
$ExpectedHash = $a.sha256.ToLower()
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $ExpectedHash) {
|
||||
Write-Error "No manifest entry for windows/$ArchTag - refusing to install."
|
||||
Remove-Item $TmpBinary -Force
|
||||
exit 1
|
||||
}
|
||||
$ActualManifestHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
|
||||
if ($ActualManifestHash -ne $ExpectedHash) {
|
||||
Write-Error "Binary hash does not match the signed manifest - possible tampering."
|
||||
Write-Error " expected (signed manifest): $ExpectedHash"
|
||||
Write-Error " actual (downloaded): $ActualManifestHash"
|
||||
Remove-Item $TmpBinary -Force
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✓ Cold-start trust established: binary matches signed release manifest ($ActualManifestHash)" -ForegroundColor Green
|
||||
# --- end cold-start manifest verification ---
|
||||
|
||||
# Verify checksum if server provided one
|
||||
$ExpectedChecksum = $null
|
||||
if ($Response.Headers.ContainsKey("X-Content-SHA256")) {
|
||||
|
|
@ -240,10 +323,10 @@ if (Test-Path $ConfigPath) {
|
|||
"agent_id": "",
|
||||
"token": "",
|
||||
"refresh_token": "",
|
||||
"registration_token": "{{.RegistrationToken}}",
|
||||
"registration_token": "$RegistrationToken",
|
||||
"machine_id": "",
|
||||
"check_in_interval": 300,
|
||||
"server_url": "{{.ServerURL}}",
|
||||
"server_url": "$ServerUrl",
|
||||
"network": {
|
||||
"timeout": 30000000000,
|
||||
"retry_count": 3,
|
||||
|
|
@ -307,9 +390,9 @@ if (Test-Path $ConfigPath) {
|
|||
if ($ExistingRefreshToken -ne "") {
|
||||
Write-Host "[INFO] [installer] [register] Upgrade in place - existing credentials detected, skipping registration" -ForegroundColor Cyan
|
||||
Write-Host "[INFO] [installer] [register] Token in URL is ignored on the upgrade path; refresh_token authenticates" -ForegroundColor Gray
|
||||
} elseif ("{{.RegistrationToken}}" -ne "") {
|
||||
} elseif ($RegistrationToken -ne "") {
|
||||
Write-Host "[INFO] [installer] [register] Registering agent with server..." -ForegroundColor Cyan
|
||||
$RegisterProcess = Start-Process -FilePath $AgentBinary -ArgumentList "--server", "{{.ServerURL}}", "--token", "{{.RegistrationToken}}", "--register" -Wait -PassThru -NoNewWindow
|
||||
$RegisterProcess = Start-Process -FilePath $AgentBinary -ArgumentList "--server", "$ServerUrl", "--token", "$RegistrationToken", "--register" -Wait -PassThru -NoNewWindow
|
||||
if ($RegisterProcess.ExitCode -eq 0) {
|
||||
Write-Host "[SUCCESS] [installer] [register] Agent registered successfully" -ForegroundColor Green
|
||||
Write-Host "[INFO] [installer] [register] Agent ID assigned, configuration updated" -ForegroundColor Gray
|
||||
|
|
@ -318,7 +401,7 @@ if ($ExistingRefreshToken -ne "") {
|
|||
Write-Host "[WARN] [installer] [register] Agent installed but not registered. Service will not start." -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
Write-Host "[INFO] [installer] [register] To retry registration manually:" -ForegroundColor Gray
|
||||
Write-Host "[INFO] [installer] [register] $AgentBinary --server {{.ServerURL}} --token YOUR_TOKEN --register" -ForegroundColor Gray
|
||||
Write-Host "[INFO] [installer] [register] $AgentBinary --server $ServerUrl --token YOUR_TOKEN --register" -ForegroundColor Gray
|
||||
Write-Host "[INFO] [installer] [register] Then start service:" -ForegroundColor Gray
|
||||
Write-Host "[INFO] [installer] [register] Start-Service -Name $ServiceName" -ForegroundColor Gray
|
||||
exit 1
|
||||
|
|
@ -327,7 +410,7 @@ if ($ExistingRefreshToken -ne "") {
|
|||
Write-Host "[INFO] [installer] [register] No registration token provided - skipping registration" -ForegroundColor Gray
|
||||
Write-Host "[INFO] [installer] [register] Service will start but agent will exit until registered" -ForegroundColor Gray
|
||||
Write-Host "[INFO] [installer] [register] To register manually:" -ForegroundColor Gray
|
||||
Write-Host "[INFO] [installer] [register] $AgentBinary --server {{.ServerURL}} --token YOUR_TOKEN --register" -ForegroundColor Gray
|
||||
Write-Host "[INFO] [installer] [register] $AgentBinary --server $ServerUrl --token YOUR_TOKEN --register" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Step 6: Install Windows service (if not skipped)
|
||||
|
|
|
|||
Loading…
Reference in a new issue