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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@ composition tests. The `local_file` backend requires
reloads after restart, rejects corrupt snapshots, and appends JSONL audit rows
for persistent trust decisions. `production_policy: true` rejects `local_file`
unless `allow_production_local_file: true` is explicitly set by the host. The
`object_store` backend is a local object-store emulator for scenarios and host
integration tests that need durable object-shaped trust custody before a live
cloud adapter is approved. It requires `allow_object_store_backend: true` and
`storage_path`, writes one checksum-protected trust snapshot object under a
hash-derived key for the `store_ref`, reloads after restart, and rejects
tampered snapshots or same-store objects found under the wrong key. It is
always rejected under `production_policy: true`; production deployments should
use the host-managed HTTP backend or a future approved storage adapter with its
own credential, audit, and abuse-control boundary. The
`http` backend delegates trust snapshot custody to a host-managed service. It
requires `endpoint_url`, rejects URL userinfo, rejects plain `http://` unless
`allow_insecure_http: true` is set for local loopback fixtures, optionally sends
Expand Down
13 changes: 11 additions & 2 deletions internal/contracts/signal.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/contracts/signal.proto
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ message SignalTrustStoreConfig {
string auth_header_env = 11;
uint32 request_timeout_ms = 12;
bool allow_insecure_http = 13;
bool allow_object_store_backend = 14;
}

message SignalTrustObserveConfig {
Expand Down
120 changes: 119 additions & 1 deletion internal/trust_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const (
signalTrustBackendMemory = "memory"
signalTrustBackendLocalFile = "local_file"
signalTrustBackendHTTP = "http"
signalTrustBackendObject = "object_store"
signalTrustSchemaVersion = 1

defaultSignalTrustHTTPTimeout = 5 * time.Second
Expand Down Expand Up @@ -84,6 +85,13 @@ type signalTrustSnapshot struct {
State signalTrustSnapshotStateV1 `json:"state"`
}

type signalTrustObject struct {
SchemaVersion int `json:"schema_version"`
Backend string `json:"backend"`
StoreRef string `json:"store_ref"`
Snapshot *signalTrustSnapshot `json:"snapshot"`
}

type signalTrustSnapshotStateV1 struct {
Records map[string]*signalTrustRecord `json:"records"`
Events []*signalTrustEvent `json:"events,omitempty"`
Expand Down Expand Up @@ -174,6 +182,16 @@ func (m *signalTrustStoreModule) Init() error {
if cfg.GetProductionPolicy() && !cfg.GetAllowProductionLocalFile() {
return fmt.Errorf("signal trust store: production policy rejects local_file backend")
}
case signalTrustBackendObject:
if !cfg.GetAllowObjectStoreBackend() {
return fmt.Errorf("signal trust store: object_store backend requires allow_object_store_backend")
}
if cfg.GetStoragePath() == "" {
return fmt.Errorf("signal trust store: storage_path is required")
}
if cfg.GetProductionPolicy() {
return fmt.Errorf("signal trust store: production policy rejects object_store backend")
}
case signalTrustBackendHTTP:
parsed, client, err := signalTrustHTTPConfig(cfg)
if err != nil {
Expand Down Expand Up @@ -208,7 +226,7 @@ func (m *signalTrustStoreModule) Init() error {
records: map[string]*signalTrustRecord{},
events: []*signalTrustEvent{},
}
if backend == signalTrustBackendLocalFile {
if backend == signalTrustBackendLocalFile || backend == signalTrustBackendObject {
if err := store.load(); err != nil {
return err
}
Expand Down Expand Up @@ -457,6 +475,8 @@ func (s *signalTrustStore) load() error {
switch s.backend {
case signalTrustBackendLocalFile:
return s.loadLocalFile()
case signalTrustBackendObject:
return s.loadObjectStore()
case signalTrustBackendHTTP:
return s.loadHTTP(context.Background())
default:
Expand All @@ -482,6 +502,68 @@ func (s *signalTrustStore) loadLocalFile() error {
return nil
}

func (s *signalTrustStore) loadObjectStore() error {
root := filepath.Join(s.path, "trust")
if err := os.MkdirAll(root, 0o700); err != nil {
return fmt.Errorf("signal trust store: create object store dir: %w", err)
}
var loaded bool
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() || entry.Name() != "snapshot.json" {
return nil
}
object, err := s.readObjectStoreSnapshot(path)
if err != nil {
return err
}
if object.StoreRef != s.ref {
return nil
}
if loaded {
return fmt.Errorf("signal trust store: object store contains duplicate snapshot for %q", safeSignalTrustRefForError(s.ref))
}
if path != s.objectStoreSnapshotPath() {
return fmt.Errorf("signal trust store: object key mismatch for %q", safeSignalTrustRefForError(s.ref))
}
Comment on lines +522 to +530
if object.Snapshot == nil {
return fmt.Errorf("signal trust store: object snapshot is required")
}
if err := s.applySnapshot(*object.Snapshot); err != nil {
return err
}
loaded = true
return nil
})
if err != nil {
return fmt.Errorf("signal trust store: read object store: %w", err)
}
return nil
}

func (s *signalTrustStore) readObjectStoreSnapshot(path string) (*signalTrustObject, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("signal trust store: read object %q: %w", filepath.Base(path), err)
}
var object signalTrustObject
if err := json.Unmarshal(raw, &object); err != nil {
return nil, fmt.Errorf("signal trust store: decode object %q: %w", filepath.Base(path), err)
}
Comment on lines +546 to +554
if object.SchemaVersion != signalTrustSchemaVersion {
return nil, fmt.Errorf("signal trust store: unsupported object version %d", object.SchemaVersion)
}
if object.Backend != signalTrustBackendObject {
return nil, fmt.Errorf("signal trust store: object backend=%q, want %q", object.Backend, signalTrustBackendObject)
}
if object.StoreRef == "" {
return nil, fmt.Errorf("signal trust store: object store_ref is required")
}
return &object, nil
}

func (s *signalTrustStore) applySnapshot(snapshot signalTrustSnapshot) error {
if snapshot.SchemaVersion != signalTrustSchemaVersion {
return fmt.Errorf("signal trust store: unsupported storage version %d", snapshot.SchemaVersion)
Expand Down Expand Up @@ -511,6 +593,8 @@ func (s *signalTrustStore) persistLocked() error {
switch s.backend {
case signalTrustBackendLocalFile:
return s.persistLocalFileLocked()
case signalTrustBackendObject:
return s.persistObjectStoreLocked()
case signalTrustBackendHTTP:
return s.persistHTTPLocked(context.Background())
default:
Expand Down Expand Up @@ -557,6 +641,40 @@ func (s *signalTrustStore) persistLocalFileLocked() error {
return nil
}

func (s *signalTrustStore) persistObjectStoreLocked() error {
snapshot, err := s.snapshotLocked()
if err != nil {
return err
}
object := signalTrustObject{
SchemaVersion: signalTrustSchemaVersion,
Backend: signalTrustBackendObject,
StoreRef: s.ref,
Snapshot: snapshot,
}
raw, err := json.MarshalIndent(object, "", " ")
if err != nil {
return fmt.Errorf("signal trust store: encode object: %w", err)
}
objectPath := s.objectStoreSnapshotPath()
if err := os.MkdirAll(filepath.Dir(objectPath), 0o700); err != nil {
return fmt.Errorf("signal trust store: create object dir: %w", err)
}
tmp := objectPath + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return fmt.Errorf("signal trust store: write object temp: %w", err)
}
if err := os.Rename(tmp, objectPath); err != nil {
return fmt.Errorf("signal trust store: replace object: %w", err)
}
return nil
}

func (s *signalTrustStore) objectStoreSnapshotPath() string {
sum := sha256.Sum256([]byte(s.ref))
return filepath.Join(s.path, "trust", hex.EncodeToString(sum[:]), "snapshot.json")
}

func (s *signalTrustStore) auditLocked(entry signalTrustAuditEntry) error {
event := s.appendEventLocked(entry)
if err := s.persistLocked(); err != nil {
Expand Down
Loading