Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2513,6 +2513,36 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}

/api/v1/reports/signing-key:
get:
operationId: getReportSigningKey
summary: The public key for verifying report signatures
description: |
Returns the Ed25519 public key (with its key id) used to sign
report snapshots, so a caller can verify a report's signature over
its content_sha256 offline. RBAC: host:read. Spec api-reports.
responses:
'200':
description: The report signing public key
content:
application/json:
schema: {$ref: '#/components/schemas/ReportSigningKey'}
'401':
description: Caller is not authenticated
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
'403':
description: Caller lacks host:read permission
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}
'503':
description: No signer configured
content:
application/json:
schema: {$ref: '#/components/schemas/ErrorEnvelope'}

/api/v1/reports/{id}:
get:
operationId: getReportByID
Expand Down Expand Up @@ -5332,7 +5362,7 @@ components:

Report:
type: object
required: [id, title, kind, scope_label, scope, data_as_of, generated_by, format, content, created_at]
required: [id, title, kind, scope_label, scope, data_as_of, generated_by, format, content, content_sha256, created_at]
properties:
id: {type: string, format: uuid}
title: {type: string}
Expand All @@ -5352,8 +5382,40 @@ components:
compliance_pct, host_count, passing_rules, failing_rules,
critical_issues, top_failing_rules, and coverage (hosts_total,
hosts_fresh, hosts_stale, hosts_unreachable).
content_sha256:
type: string
description: |
The snapshot's content address: hex SHA-256 of the canonical
content (the json export face reproduces these exact bytes).
signature:
type: string
description: |
Base64 Ed25519 signature over the content address, or absent
for an unsigned snapshot. Verify with the key from
GET /api/v1/reports/signing-key.
signing_key_id:
type: string
description: Fingerprint of the key that produced the signature.
created_at: {type: string, format: date-time}

ReportSigningKey:
type: object
required: [key_id, algorithm, public_key, ephemeral]
description: |
The public key used to sign report snapshots, for offline
verification of a report's signature over its content_sha256.
properties:
key_id: {type: string}
algorithm: {type: string, enum: [ed25519]}
public_key:
type: string
description: Base64-encoded Ed25519 public key.
ephemeral:
type: boolean
description: |
True when the server runs a per-boot development key (no durable
key configured); such signatures do not verify across restarts.

ReportListResponse:
type: object
required: [reports]
Expand Down
20 changes: 19 additions & 1 deletion cmd/openwatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,24 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
Sched: complianceSched,
})

// Report signing key. Optional: an empty path yields an ephemeral
// per-boot key (development) so reports still sign; production sets a
// durable key so signatures verify across restarts.
reportSigner, err := report.NewSigner(cfg.Reports.SigningKeyFile)
if err != nil {
slog.ErrorContext(bootCtx, "failed to load report signing key",
slog.String("path", cfg.Reports.SigningKeyFile),
slog.String("error", err.Error()))
return 1
}
if reportSigner.Ephemeral() {
slog.WarnContext(bootCtx, "report signing key is EPHEMERAL (per-boot) — DEVELOPMENT ONLY; set [reports].signing_key_file (OPENWATCH_REPORTS_SIGNING_KEY_FILE) in production so report signatures verify across restarts",
slog.String("key_id", reportSigner.KeyID()))
} else {
slog.InfoContext(bootCtx, "report signing key loaded",
slog.String("key_id", reportSigner.KeyID()))
}

srv := server.New(cfg, pool).
WithConnectivityConfig(cfgStore, liveSvc).
WithDiscovery(discoSvc).
Expand All @@ -651,7 +669,7 @@ func cmdServe(cfg *config.Config, _ []string, stdout, stderr *os.File) int {
WithExceptions(exceptionSvc).
WithRemediation(remediationSvc).
WithGroups(group.NewService(pool)).
WithReports(report.NewService(pool).WithGroups(group.NewService(pool))).
WithReports(report.NewService(pool).WithGroups(group.NewService(pool)).WithSigner(reportSigner)).
WithScanResults(scanresult.NewReader(pool)).
WithNotifications(notifSvc)
runErr := srv.Run(ctx)
Expand Down
98 changes: 98 additions & 0 deletions frontend/src/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,28 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/reports/signing-key": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* The public key for verifying report signatures
* @description Returns the Ed25519 public key (with its key id) used to sign
* report snapshots, so a caller can verify a report's signature over
* its content_sha256 offline. RBAC: host:read. Spec api-reports.
*/
get: operations["getReportSigningKey"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/reports/{id}": {
parameters: {
query?: never;
Expand Down Expand Up @@ -3549,9 +3571,38 @@ export interface components {
content: {
[key: string]: unknown;
};
/**
* @description The snapshot's content address: hex SHA-256 of the canonical
* content (the json export face reproduces these exact bytes).
*/
content_sha256: string;
/**
* @description Base64 Ed25519 signature over the content address, or absent
* for an unsigned snapshot. Verify with the key from
* GET /api/v1/reports/signing-key.
*/
signature?: string;
/** @description Fingerprint of the key that produced the signature. */
signing_key_id?: string;
/** Format: date-time */
created_at: string;
};
/**
* @description The public key used to sign report snapshots, for offline
* verification of a report's signature over its content_sha256.
*/
ReportSigningKey: {
key_id: string;
/** @enum {string} */
algorithm: "ed25519";
/** @description Base64-encoded Ed25519 public key. */
public_key: string;
/**
* @description True when the server runs a per-boot development key (no durable
* key configured); such signatures do not verify across restarts.
*/
ephemeral: boolean;
};
ReportListResponse: {
reports: components["schemas"]["Report"][];
};
Expand Down Expand Up @@ -7551,6 +7602,53 @@ export interface operations {
};
};
};
getReportSigningKey: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The report signing public key */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReportSigningKey"];
};
};
/** @description Caller is not authenticated */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description Caller lacks host:read permission */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
/** @description No signer configured */
503: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorEnvelope"];
};
};
};
};
getReportByID: {
parameters: {
query?: never;
Expand Down
11 changes: 11 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ type Config struct {
Database DatabaseConfig `toml:"database"`
Logging LoggingConfig `toml:"logging"`
Identity IdentityConfig `toml:"identity"`
Reports ReportsConfig `toml:"reports"`
}

// ReportsConfig governs report-snapshot signing.
type ReportsConfig struct {
// SigningKeyFile points at a 32-byte raw Ed25519 seed (mode 0600)
// used to sign report snapshots over their content address. Optional:
// when empty, `openwatch serve` runs with an EPHEMERAL per-boot key
// (development) and logs a warning; production MUST set a durable key
// so signatures verify across restarts. Never stored in the DB.
SigningKeyFile string `toml:"signing_key_file"`
}

// IdentityConfig holds paths to the at-rest cryptographic material the
Expand Down
2 changes: 2 additions & 0 deletions internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ var envOverrides = []envOverride{

{"OPENWATCH_IDENTITY_JWT_PRIVATE_KEY", func(c *Config, v string) error { c.Identity.JWTPrivateKey = v; return nil }},
{"OPENWATCH_IDENTITY_CREDENTIAL_KEY_FILE", func(c *Config, v string) error { c.Identity.CredentialKeyFile = v; return nil }},

{"OPENWATCH_REPORTS_SIGNING_KEY_FILE", func(c *Config, v string) error { c.Reports.SigningKeyFile = v; return nil }},
}

// applyEnv consults each registered env-var; the lookup returns ok=false for
Expand Down
16 changes: 14 additions & 2 deletions internal/report/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,20 @@ func (s *Service) Export(ctx context.Context, id uuid.UUID, face string) ([]byte
}
switch face {
case FaceJSON:
// The canonical snapshot content IS the json face; no caching.
return rep.Content, "application/json", nil
// Canonical content bytes: re-marshal the decoded content so the
// json face reproduces content_sha256 byte-for-byte (the stored
// jsonb column is Postgres-normalized, not identical to the bytes
// that were hashed and signed). This makes the snapshot
// offline-verifiable: sha256(json face) == content_sha256.
var c ExecutiveContent
if err := json.Unmarshal(rep.Content, &c); err != nil {
return nil, "", fmt.Errorf("report: decode content for json face: %w", err)
}
canonical, err := json.Marshal(c)
if err != nil {
return nil, "", fmt.Errorf("report: marshal canonical json: %w", err)
}
return canonical, "application/json", nil
case FacePDF:
return s.exportPDF(ctx, rep)
default:
Expand Down
9 changes: 7 additions & 2 deletions internal/report/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ package report
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"testing"
"time"
Expand Down Expand Up @@ -87,8 +89,11 @@ func TestExport_FacesAndCaching(t *testing.T) {
if jsonMedia != "application/json" {
t.Errorf("json media = %q", jsonMedia)
}
if !bytes.Equal(jsonBody, rep.Content) {
t.Errorf("json face is not the canonical content")
// The json face is canonical: its sha256 reproduces content_sha256,
// so a verifier can confirm the content matches the signed hash.
sum := sha256.Sum256(jsonBody)
if hex.EncodeToString(sum[:]) != rep.ContentSHA256 {
t.Errorf("json face sha256 (%x) != content_sha256 (%s)", sum, rep.ContentSHA256)
}

// pdf face renders and caches.
Expand Down
8 changes: 6 additions & 2 deletions internal/report/pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,12 @@ func renderExecutivePDF(rep Report, c ExecutiveContent) ([]byte, error) {
if len(short) > 16 {
short = short[:16]
}
foot := fmt.Sprintf("OpenWatch executive report · content %s… · point-in-time, not signed (MVP) · %s",
short, time.Now().UTC().Format("2006-01-02"))
signed := "not signed"
if len(rep.Signature) > 0 {
signed = "signed " + rep.SigningKeyID
}
foot := fmt.Sprintf("OpenWatch executive report · content %s… · %s · %s",
short, signed, time.Now().UTC().Format("2006-01-02"))
pdf.CellFormat(0, 5, tr(foot), "", 0, "L", false, 0, "")

var buf bytes.Buffer
Expand Down
Loading
Loading