Skip to content

Commit 1b797eb

Browse files
committed
feat: configugre regionkey provider
1 parent 6e2f7d2 commit 1b797eb

4 files changed

Lines changed: 274 additions & 0 deletions

File tree

config/config.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"encoding/base64"
45
"errors"
56
"fmt"
67
"os"
@@ -95,6 +96,14 @@ type Config struct {
9596
// (and Ship defaults to true) — e.g. to configure the catalog never to ship.
9697
CatalogPlane PlaneSettings `mapstructure:"catalog_plane" yaml:"catalog_plane"`
9798

99+
// RegionKey selects and configures the region CEK wrap provider
100+
// (regionkey.Provider): the component that wraps each object's
101+
// content-encryption key under the region KEK for the read path (the
102+
// FilOne encryption design's region wrap). Optional until the encrypting
103+
// put/get paths are wired; anything consuming the provider fails at
104+
// startup when it is unconfigured.
105+
RegionKey RegionKeyConfig `mapstructure:"regionkey" yaml:"regionkey"`
106+
98107
// LogLevel is the zap level (debug|info|warn|error).
99108
LogLevel string `mapstructure:"log_level" yaml:"log_level"`
100109
// PostgresDSN is the registry/meta database.
@@ -224,6 +233,48 @@ type IdentityConfig struct {
224233
KeyFile string `mapstructure:"key_file" yaml:"key_file"`
225234
}
226235

236+
// RegionKeyConfig selects the region CEK wrap provider and carries each
237+
// implementation's settings.
238+
type RegionKeyConfig struct {
239+
// Provider names the implementation: "openbao" (production — the wrap
240+
// runs inside the region's OpenBao transit engine and the KEK never
241+
// enters ingot's process) or "inprocess" (AES-256-GCM in ingot's own
242+
// process; tests and development only). Empty means unconfigured.
243+
Provider string `mapstructure:"provider" yaml:"provider"`
244+
// OpenBao configures the "openbao" provider.
245+
OpenBao OpenBaoConfig `mapstructure:"openbao" yaml:"openbao"`
246+
// InProcess configures the "inprocess" provider.
247+
InProcess InProcessConfig `mapstructure:"inprocess" yaml:"inprocess"`
248+
}
249+
250+
// OpenBaoConfig is the "openbao" region-key provider's connection and key
251+
// settings.
252+
type OpenBaoConfig struct {
253+
// Address of the OpenBao server, e.g. "https://bao.region.internal:8200"
254+
// or a unix socket "unix:///run/openbao/api.sock". Empty falls back to
255+
// the client's environment (BAO_ADDR, or upstream VAULT_ADDR).
256+
Address string `mapstructure:"address" yaml:"address"`
257+
// Token authenticates ingot to OpenBao; it needs encrypt/decrypt/rewrap
258+
// on the transit key and nothing else. Empty falls back to the client's
259+
// environment (BAO_TOKEN, or upstream VAULT_TOKEN).
260+
Token string `mapstructure:"token" yaml:"token"`
261+
// Mount is the transit engine's mount path. Empty means "transit".
262+
Mount string `mapstructure:"mount" yaml:"mount"`
263+
// Key is the transit key name holding the region KEK (provisioned with
264+
// type aes256-gcm96 and derived=true). Required when provider=openbao.
265+
Key string `mapstructure:"key" yaml:"key"`
266+
}
267+
268+
// InProcessConfig is the "inprocess" region-key provider's settings.
269+
type InProcessConfig struct {
270+
// KEK is the region key, base64-encoded 32 bytes. Empty generates a
271+
// random key at startup — development only: wraps made under a generated
272+
// key are unreadable after a restart.
273+
KEK string `mapstructure:"kek" yaml:"kek"`
274+
// Version tags wraps with the KEK's version. Empty means "v1".
275+
Version string `mapstructure:"version" yaml:"version"`
276+
}
277+
227278
// Load reads daemon config from configFile (or the default search path)
228279
// with env override (INGOT_* / nested keys via "_").
229280
func Load(configFile string) (*Config, error) {
@@ -263,6 +314,17 @@ func Load(configFile string) (*Config, error) {
263314
func setDefaults(v *viper.Viper) {
264315
v.SetDefault("log_level", "info")
265316
v.SetDefault("addr", "0.0.0.0:9000")
317+
// The regionkey keys are registered even where the default is empty:
318+
// viper's AutomaticEnv only overrides keys it already knows, so without
319+
// these an INGOT_REGIONKEY_* env var would be silently ignored whenever
320+
// the key is absent from the YAML.
321+
v.SetDefault("regionkey.provider", "")
322+
v.SetDefault("regionkey.openbao.address", "")
323+
v.SetDefault("regionkey.openbao.token", "")
324+
v.SetDefault("regionkey.openbao.mount", "transit")
325+
v.SetDefault("regionkey.openbao.key", "")
326+
v.SetDefault("regionkey.inprocess.kek", "")
327+
v.SetDefault("regionkey.inprocess.version", "v1")
266328
}
267329

268330
// Validate checks the config for the selected mode, aggregating every
@@ -308,6 +370,26 @@ func (c *Config) Validate() error {
308370
errs = multierr.Append(errs, errors.New("revocation_service_url and revocation_service_did must be set together"))
309371
}
310372

373+
switch c.RegionKey.Provider {
374+
case "":
375+
// Unconfigured is valid until the encrypting put/get paths are wired.
376+
case "openbao":
377+
if c.RegionKey.OpenBao.Key == "" {
378+
errs = multierr.Append(errs, errors.New("regionkey.openbao.key (transit key name) is required when regionkey.provider is openbao"))
379+
}
380+
case "inprocess":
381+
if c.RegionKey.InProcess.KEK != "" {
382+
kek, err := base64.StdEncoding.DecodeString(c.RegionKey.InProcess.KEK)
383+
if err != nil {
384+
errs = multierr.Append(errs, fmt.Errorf("regionkey.inprocess.kek: %w", err))
385+
} else if len(kek) != 32 {
386+
errs = multierr.Append(errs, fmt.Errorf("regionkey.inprocess.kek must decode to 32 bytes (AES-256), got %d", len(kek)))
387+
}
388+
}
389+
default:
390+
errs = multierr.Append(errs, fmt.Errorf("regionkey.provider %q is not one of openbao, inprocess", c.RegionKey.Provider))
391+
}
392+
311393
if errs != nil {
312394
return fmt.Errorf("invalid config: %w", errs)
313395
}

config/config_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package config
22

33
import (
4+
"encoding/base64"
45
"os"
56
"path/filepath"
67
"strings"
@@ -56,6 +57,16 @@ func TestValidate_RequiredFields(t *testing.T) {
5657
{"revocation did without url", func(c *Config) { c.RevocationServiceDID = "did:web:swarf.example" }, "revocation_service_url and revocation_service_did must be set together"},
5758
{"bad seal_age", func(c *Config) { c.SealAge = "not-a-duration" }, "parse seal_age"},
5859
{"bad cors origin", func(c *Config) { c.CORSAllowedOrigins = []string{"app.example"} }, "cors_allowed_origins"},
60+
{"unknown regionkey provider", func(c *Config) { c.RegionKey.Provider = "hsm" }, `regionkey.provider "hsm" is not one of openbao, inprocess`},
61+
{"openbao without key", func(c *Config) { c.RegionKey.Provider = "openbao" }, "regionkey.openbao.key"},
62+
{"inprocess kek not base64", func(c *Config) {
63+
c.RegionKey.Provider = "inprocess"
64+
c.RegionKey.InProcess.KEK = "not-base64!!"
65+
}, "regionkey.inprocess.kek"},
66+
{"inprocess kek wrong length", func(c *Config) {
67+
c.RegionKey.Provider = "inprocess"
68+
c.RegionKey.InProcess.KEK = "c2hvcnQ=" // "short"
69+
}, "must decode to 32 bytes"},
5970
}
6071
for _, tc := range cases {
6172
t.Run(tc.name, func(t *testing.T) {
@@ -80,6 +91,31 @@ func TestValidate_RevocationServicePair(t *testing.T) {
8091
}
8192
}
8293

94+
// TestValidate_RegionKey: both providers validate with their required
95+
// settings present; unconfigured (empty provider) stays valid until the
96+
// encrypting paths are wired.
97+
func TestValidate_RegionKey(t *testing.T) {
98+
cfg := validConfig(t)
99+
cfg.RegionKey.Provider = "openbao"
100+
cfg.RegionKey.OpenBao.Key = "region-kek"
101+
if err := cfg.Validate(); err != nil {
102+
t.Fatalf("expected valid openbao regionkey config, got: %v", err)
103+
}
104+
105+
cfg = validConfig(t)
106+
cfg.RegionKey.Provider = "inprocess"
107+
cfg.RegionKey.InProcess.KEK = base64.StdEncoding.EncodeToString(make([]byte, 32))
108+
if err := cfg.Validate(); err != nil {
109+
t.Fatalf("expected valid inprocess regionkey config, got: %v", err)
110+
}
111+
112+
cfg = validConfig(t)
113+
cfg.RegionKey.Provider = "inprocess" // empty KEK: generated at startup
114+
if err := cfg.Validate(); err != nil {
115+
t.Fatalf("expected valid inprocess config with no KEK, got: %v", err)
116+
}
117+
}
118+
83119
// TestValidate_AuthServiceProofs: the optional proofs value is loaded eagerly
84120
// so a bad path or encoding fails at startup.
85121
func TestValidate_AuthServiceProofs(t *testing.T) {

module.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ package ingot
4444

4545
import (
4646
"context"
47+
"crypto/rand"
48+
"encoding/base64"
4749
"fmt"
4850
"net/url"
4951

@@ -53,6 +55,7 @@ import (
5355
"github.com/fil-forge/ucantone/ucan"
5456
"github.com/fil-forge/versitygw/auth"
5557
"github.com/jackc/pgx/v5/pgxpool"
58+
"github.com/openbao/openbao/api/v2"
5659
"go.uber.org/fx"
5760
"go.uber.org/zap"
5861

@@ -64,6 +67,7 @@ import (
6467
"github.com/fil-forge/ingot/iam"
6568
"github.com/fil-forge/ingot/logstore"
6669
"github.com/fil-forge/ingot/migrations"
70+
"github.com/fil-forge/ingot/regionkey"
6771
"github.com/fil-forge/ingot/registry"
6872
"github.com/fil-forge/ingot/revocation"
6973
"github.com/fil-forge/ingot/tokenstore"
@@ -112,6 +116,7 @@ func Module(cfg config.Config) fx.Option {
112116
provideKeyProofs,
113117
provideVerificationKeyCache,
114118
provideIAMService,
119+
provideRegionKeyProvider,
115120
fx.Annotate(bucketauthority.New, fx.As(new(bucketauthority.BucketAuthority))),
116121
),
117122
ServerModule,
@@ -286,6 +291,54 @@ func provideAuthServiceClient(cfg config.Config, id ServiceIdentity, logger *zap
286291
return hiltclient.New(authServiceDID, *authServiceURL, id.Signer, hiltclient.WithBaseProofs(proofs), hiltclient.WithLogger(logger))
287292
}
288293

294+
// provideRegionKeyProvider builds the configured region CEK wrap provider
295+
// (config `regionkey.provider`): "openbao" runs the wrap inside the region's
296+
// OpenBao transit engine (the production choice — the region KEK never enters
297+
// this process), "inprocess" wraps with AES-256-GCM in process (tests and
298+
// development). The provider is lazy, so an unconfigured region key only
299+
// errors if a consumer actually needs it.
300+
func provideRegionKeyProvider(cfg config.Config, logger *zap.Logger) (regionkey.Provider, error) {
301+
switch cfg.RegionKey.Provider {
302+
case "openbao":
303+
bao := cfg.RegionKey.OpenBao
304+
apiCfg := api.DefaultConfig() // reads BAO_ADDR etc. from the environment
305+
if bao.Address != "" {
306+
apiCfg.Address = bao.Address
307+
}
308+
client, err := api.NewClient(apiCfg) // reads BAO_TOKEN from the environment
309+
if err != nil {
310+
return nil, fmt.Errorf("ingot: regionkey.openbao: %w", err)
311+
}
312+
if bao.Token != "" {
313+
client.SetToken(bao.Token)
314+
}
315+
return regionkey.NewOpenBaoProvider(client, bao.Mount, bao.Key)
316+
case "inprocess":
317+
inproc := cfg.RegionKey.InProcess
318+
var kek []byte
319+
if inproc.KEK != "" {
320+
var err error
321+
kek, err = base64.StdEncoding.DecodeString(inproc.KEK)
322+
if err != nil {
323+
return nil, fmt.Errorf("ingot: regionkey.inprocess.kek: %w", err)
324+
}
325+
} else {
326+
kek = make([]byte, regionkey.KEKLen)
327+
if _, err := rand.Read(kek); err != nil {
328+
return nil, fmt.Errorf("ingot: regionkey.inprocess: generating KEK: %w", err)
329+
}
330+
logger.Warn("regionkey: inprocess provider generated a random KEK; " +
331+
"wrapped CEKs will be unreadable after a restart — set regionkey.inprocess.kek to persist one (development only)")
332+
}
333+
version := regionkey.KeyVersion(config.EmptyDefault(inproc.Version, "v1"))
334+
return regionkey.NewInProcessProvider(version, kek)
335+
case "":
336+
return nil, fmt.Errorf("ingot: regionkey.provider is not configured")
337+
default:
338+
return nil, fmt.Errorf("ingot: regionkey.provider %q is not one of openbao, inprocess", cfg.RegionKey.Provider)
339+
}
340+
}
341+
289342
// provideKeyProofs is the per-access-key delegation store registry: the IAM
290343
// service deposits each key's Hilt-issued chains into its own store and
291344
// stashes that store on the request context, from which the network read
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package ingot
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"testing"
7+
8+
"github.com/fil-forge/libforge/testutil"
9+
"github.com/multiformats/go-multihash"
10+
"go.uber.org/zap"
11+
12+
"github.com/fil-forge/ingot/config"
13+
"github.com/fil-forge/ingot/regionkey"
14+
)
15+
16+
// regionKeyCfg wraps a RegionKeyConfig into the minimal Config the provider
17+
// constructor reads.
18+
func regionKeyCfg(rk config.RegionKeyConfig) config.Config {
19+
return config.Config{RegionKey: rk}
20+
}
21+
22+
// TestProvideRegionKeyProvider_InProcess: the "inprocess" provider constructs
23+
// from a configured KEK and round-trips a wrap, proving the config path feeds
24+
// regionkey correctly end to end.
25+
func TestProvideRegionKeyProvider_InProcess(t *testing.T) {
26+
kek := make([]byte, regionkey.KEKLen)
27+
kek[0] = 0xAB
28+
p, err := provideRegionKeyProvider(regionKeyCfg(config.RegionKeyConfig{
29+
Provider: "inprocess",
30+
InProcess: config.InProcessConfig{KEK: base64.StdEncoding.EncodeToString(kek), Version: "v7"},
31+
}), zap.NewNop())
32+
if err != nil {
33+
t.Fatalf("provideRegionKeyProvider: %v", err)
34+
}
35+
36+
digest, err := multihash.Sum([]byte("blob-1"), multihash.SHA2_256, -1)
37+
if err != nil {
38+
t.Fatalf("multihash: %v", err)
39+
}
40+
binding := regionkey.BindingContext{Space: testutil.RandomDID(t), Digest: digest}
41+
cek := []byte("0123456789abcdef0123456789abcdef")
42+
43+
wrapped, err := p.Wrap(context.Background(), binding, cek)
44+
if err != nil {
45+
t.Fatalf("Wrap: %v", err)
46+
}
47+
if wrapped.Version != "v7" {
48+
t.Fatalf("Version = %q, want the configured v7", wrapped.Version)
49+
}
50+
got, err := p.Unwrap(context.Background(), binding, wrapped)
51+
if err != nil {
52+
t.Fatalf("Unwrap: %v", err)
53+
}
54+
if string(got) != string(cek) {
55+
t.Fatalf("round-trip mismatch")
56+
}
57+
}
58+
59+
// An empty KEK generates one at startup (development): the provider works,
60+
// the wraps just die with the process.
61+
func TestProvideRegionKeyProvider_InProcessGeneratedKEK(t *testing.T) {
62+
p, err := provideRegionKeyProvider(regionKeyCfg(config.RegionKeyConfig{Provider: "inprocess"}), zap.NewNop())
63+
if err != nil {
64+
t.Fatalf("provideRegionKeyProvider: %v", err)
65+
}
66+
wrapped, err := p.Wrap(context.Background(), regionkey.BindingContext{}, []byte("a 32-byte content encryption k."))
67+
if err != nil {
68+
t.Fatalf("Wrap: %v", err)
69+
}
70+
if wrapped.Version != "v1" {
71+
t.Fatalf("Version = %q, want the default v1", wrapped.Version)
72+
}
73+
}
74+
75+
// TestProvideRegionKeyProvider_OpenBao: with a key configured the provider
76+
// constructs (no connection is made until the first wrap); without one it
77+
// fails.
78+
func TestProvideRegionKeyProvider_OpenBao(t *testing.T) {
79+
if _, err := provideRegionKeyProvider(regionKeyCfg(config.RegionKeyConfig{
80+
Provider: "openbao",
81+
OpenBao: config.OpenBaoConfig{Address: "http://127.0.0.1:8200", Key: "region-kek"},
82+
}), zap.NewNop()); err != nil {
83+
t.Fatalf("provideRegionKeyProvider: %v", err)
84+
}
85+
if _, err := provideRegionKeyProvider(regionKeyCfg(config.RegionKeyConfig{Provider: "openbao"}), zap.NewNop()); err == nil {
86+
t.Fatal("expected an error with no transit key configured")
87+
}
88+
}
89+
90+
func TestProvideRegionKeyProvider_Selection(t *testing.T) {
91+
if _, err := provideRegionKeyProvider(regionKeyCfg(config.RegionKeyConfig{}), zap.NewNop()); err == nil {
92+
t.Fatal("expected an error for an unconfigured provider")
93+
}
94+
if _, err := provideRegionKeyProvider(regionKeyCfg(config.RegionKeyConfig{Provider: "hsm"}), zap.NewNop()); err == nil {
95+
t.Fatal("expected an error for an unknown provider")
96+
}
97+
if _, err := provideRegionKeyProvider(regionKeyCfg(config.RegionKeyConfig{
98+
Provider: "inprocess",
99+
InProcess: config.InProcessConfig{KEK: "not-base64!!"},
100+
}), zap.NewNop()); err == nil {
101+
t.Fatal("expected an error for a malformed KEK")
102+
}
103+
}

0 commit comments

Comments
 (0)