From 6e657e7c783e30dea3b1c0cf47a312e107440788 Mon Sep 17 00:00:00 2001 From: mitchell Date: Mon, 22 Jun 2026 14:52:43 -0400 Subject: [PATCH] Add org key custody backend: client-hosted HTTPS key service (ENG-1632) Add the key-custody backend for private ingredients: a provider that fetches the organization's single AES-256 key from the customer-hosted HTTPS key service, validates it, and caches it for the run. - internal/runbits/orgkey: the Provider, the v1 org-key contract validation (schema/org/algorithm/encoding, base64 decode, fingerprint match via artifactcrypto), and the HTTPS backend (https-only, TLS 1.2+ with a configured CA or pinned cert, optional mTLS, bearer token from an env var or file, bounded timeout, no redirects). The key is held in memory for the run with an opt-in 0600 on-disk cache for headless/offline/CI. - Config options for the URL, CA, mTLS cert/key, bearer-token source, and the on-disk cache opt-in, registered in the package's init() and settable via state config. - subshell scrubs the configured bearer-token env var from child process environments. - pkg/runtime gains a WithDecryptionKey option as the consume-side injection seam, and orgkey exposes a header pre-flight key check. The custody backend lives caller-side and is not imported by pkg/runtime, which stays CGO-free. Wiring into the publish and consume flows lands with ENG-1634 and ENG-1635. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/constants/constants.go | 28 +++ internal/runbits/orgkey/cache.go | 51 +++++ internal/runbits/orgkey/cache_lin_mac.go | 19 ++ internal/runbits/orgkey/cache_win.go | 12 ++ internal/runbits/orgkey/env.go | 16 ++ internal/runbits/orgkey/helpers_test.go | 131 ++++++++++++ internal/runbits/orgkey/https.go | 216 +++++++++++++++++++ internal/runbits/orgkey/https_test.go | 253 +++++++++++++++++++++++ internal/runbits/orgkey/orgkey.go | 122 +++++++++++ internal/runbits/orgkey/preflight.go | 23 +++ internal/runbits/orgkey/validate_test.go | 109 ++++++++++ internal/subshell/subshell.go | 6 +- pkg/runtime/options.go | 9 + pkg/runtime/setup.go | 6 + 14 files changed, 1000 insertions(+), 1 deletion(-) create mode 100644 internal/runbits/orgkey/cache.go create mode 100644 internal/runbits/orgkey/cache_lin_mac.go create mode 100644 internal/runbits/orgkey/cache_win.go create mode 100644 internal/runbits/orgkey/env.go create mode 100644 internal/runbits/orgkey/helpers_test.go create mode 100644 internal/runbits/orgkey/https.go create mode 100644 internal/runbits/orgkey/https_test.go create mode 100644 internal/runbits/orgkey/orgkey.go create mode 100644 internal/runbits/orgkey/preflight.go create mode 100644 internal/runbits/orgkey/validate_test.go diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 8c02da60fe..846df9b818 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -529,3 +529,31 @@ const BuildProgressUrlPathName = "distributions" // RuntimeCacheSizeConfigKey is the config key for the runtime cache size. const RuntimeCacheSizeConfigKey = "runtime.cache.size" + +// PrivateIngredientKeyServiceURLConfig is the config key holding the URL of the +// customer-hosted org key service (the GET .../v1/org-key endpoint). +const PrivateIngredientKeyServiceURLConfig = "privateingredient.key_service_url" + +// PrivateIngredientKeyServiceCAConfig is the config key holding the path to the +// CA bundle (or pinned certificate) used to verify the key service's TLS certificate. +const PrivateIngredientKeyServiceCAConfig = "privateingredient.key_service_ca" + +// PrivateIngredientMTLSCertConfig is the config key holding the path to the mTLS +// client certificate used to authenticate to the key service. +const PrivateIngredientMTLSCertConfig = "privateingredient.mtls_cert" + +// PrivateIngredientMTLSKeyConfig is the config key holding the path to the mTLS +// client private key used to authenticate to the key service. +const PrivateIngredientMTLSKeyConfig = "privateingredient.mtls_key" + +// PrivateIngredientBearerTokenEnvConfig is the config key holding the name of the +// environment variable from which to read a bearer token for the key service. +const PrivateIngredientBearerTokenEnvConfig = "privateingredient.bearer_token_env" + +// PrivateIngredientBearerTokenFileConfig is the config key holding the path to a +// file from which to read a bearer token for the key service. +const PrivateIngredientBearerTokenFileConfig = "privateingredient.bearer_token_file" + +// PrivateIngredientCacheKeyConfig is the config key that opts into caching the +// fetched org key on disk (0600) for headless/offline/CI reuse. +const PrivateIngredientCacheKeyConfig = "privateingredient.cache_key_on_disk" diff --git a/internal/runbits/orgkey/cache.go b/internal/runbits/orgkey/cache.go new file mode 100644 index 0000000000..e255341ee3 --- /dev/null +++ b/internal/runbits/orgkey/cache.go @@ -0,0 +1,51 @@ +package orgkey + +import ( + "os" + "path/filepath" + + "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/logging" +) + +// cacheFileName is the on-disk cache file (the validated contract JSON) under the config dir. +const cacheFileName = "private_ingredient_orgkey.json" + +func (p *provider) diskCacheEnabled() bool { + return p.cfg.GetBool(constants.PrivateIngredientCacheKeyConfig) +} + +func (p *provider) cachePath() string { + return filepath.Join(p.cfg.ConfigPath(), cacheFileName) +} + +// readDiskCache returns the cached contract bytes if a usable cache file exists. +// A missing file is normal (first run); an unsafe or unreadable file is ignored +// with a warning so the run falls back to a fresh fetch. +func (p *provider) readDiskCache() ([]byte, bool) { + path := p.cachePath() + info, err := os.Stat(path) + if err != nil { + return nil, false + } + if err := checkCacheMode(info); err != nil { + logging.Warning("Ignoring on-disk org key cache: %v", errs.JoinMessage(err)) + return nil, false + } + b, err := os.ReadFile(path) + if err != nil { + logging.Warning("Could not read on-disk org key cache: %v", errs.JoinMessage(err)) + return nil, false + } + return b, true +} + +// writeDiskCache persists the validated contract for reuse by later runs, +// owner-readable only. +func (p *provider) writeDiskCache(raw []byte) error { + if err := os.WriteFile(p.cachePath(), raw, 0600); err != nil { + return errs.Wrap(err, "unable to write org key cache") + } + return nil +} diff --git a/internal/runbits/orgkey/cache_lin_mac.go b/internal/runbits/orgkey/cache_lin_mac.go new file mode 100644 index 0000000000..3ea4db735a --- /dev/null +++ b/internal/runbits/orgkey/cache_lin_mac.go @@ -0,0 +1,19 @@ +//go:build !windows +// +build !windows + +package orgkey + +import ( + "os" + + "github.com/ActiveState/cli/internal/errs" +) + +// checkCacheMode rejects a cache file that is readable or writable by anyone +// other than the owner (anything beyond u+rw). +func checkCacheMode(info os.FileInfo) error { + if info.Mode()&0177 != 0 { + return errs.New("cache file %q must be mode 0600", info.Name()) + } + return nil +} diff --git a/internal/runbits/orgkey/cache_win.go b/internal/runbits/orgkey/cache_win.go new file mode 100644 index 0000000000..94970f34ca --- /dev/null +++ b/internal/runbits/orgkey/cache_win.go @@ -0,0 +1,12 @@ +//go:build windows +// +build windows + +package orgkey + +import "os" + +// checkCacheMode is a no-op on Windows, where POSIX permission bits do not +// apply; the cache file is written to the owner's config directory. +func checkCacheMode(info os.FileInfo) error { + return nil +} diff --git a/internal/runbits/orgkey/env.go b/internal/runbits/orgkey/env.go new file mode 100644 index 0000000000..b57e193002 --- /dev/null +++ b/internal/runbits/orgkey/env.go @@ -0,0 +1,16 @@ +package orgkey + +import "github.com/ActiveState/cli/internal/constants" + +// stringConfigReader reads string-valued config options. +type stringConfigReader interface { + GetString(key string) string +} + +// SanitizeChildEnv removes private-ingredient key-service credentials from env +// so they are never propagated to child process environments. +func SanitizeChildEnv(cfg stringConfigReader, env map[string]string) { + if tokenEnv := cfg.GetString(constants.PrivateIngredientBearerTokenEnvConfig); tokenEnv != "" { + delete(env, tokenEnv) + } +} diff --git a/internal/runbits/orgkey/helpers_test.go b/internal/runbits/orgkey/helpers_test.go new file mode 100644 index 0000000000..108f667f36 --- /dev/null +++ b/internal/runbits/orgkey/helpers_test.go @@ -0,0 +1,131 @@ +package orgkey + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "math/big" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/constants" +) + +// testKey is a fixed 32-byte AES-256 key used across tests. +func testKey() []byte { + k := make([]byte, artifactcrypto.KeySize) + for i := range k { + k[i] = byte(i + 1) + } + return k +} + +// fakeConfig is an in-memory implementation of configurable. +type fakeConfig struct { + strings map[string]string + bools map[string]bool + dir string +} + +func newFakeConfig(t *testing.T) *fakeConfig { + return &fakeConfig{ + strings: map[string]string{}, + bools: map[string]bool{}, + dir: t.TempDir(), + } +} + +func (f *fakeConfig) GetString(key string) string { return f.strings[key] } +func (f *fakeConfig) GetBool(key string) bool { return f.bools[key] } +func (f *fakeConfig) ConfigPath() string { return f.dir } + +// contractFields returns a valid contract as a field map, so tests can mutate +// individual fields before marshaling. +func contractFields(key []byte, org, keyID string) map[string]string { + return map[string]string{ + "schema": contractSchema, + "org": org, + "key_id": keyID, + "algorithm": contractAlgorithm, + "encoding": contractEncoding, + "key": "b64:" + base64.StdEncoding.EncodeToString(key), + "fingerprint": artifactcrypto.Fingerprint(key), + } +} + +func mustJSON(t *testing.T, v interface{}) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +// writeServerCA writes the test server's certificate to a temp PEM file and +// returns its path, for use as the configured key-service CA. +func writeServerCA(t *testing.T, srv *httptest.Server) string { + t.Helper() + path := filepath.Join(t.TempDir(), "ca.pem") + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) + if err := os.WriteFile(path, pemBytes, 0600); err != nil { + t.Fatal(err) + } + return path +} + +// genClientCert generates a self-signed client certificate and key, writes them +// to temp files, and returns their paths (for the mTLS path). +func genClientCert(t *testing.T) (certPath, keyPath string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-client"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + certPath = filepath.Join(dir, "client.crt") + keyPath = filepath.Join(dir, "client.key") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + if err := os.WriteFile(certPath, certPEM, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + t.Fatal(err) + } + return certPath, keyPath +} + +// configForServer returns a fakeConfig pointed at srv with its CA trusted. +func configForServer(t *testing.T, srv *httptest.Server) *fakeConfig { + cfg := newFakeConfig(t) + cfg.strings[constants.PrivateIngredientKeyServiceURLConfig] = srv.URL + cfg.strings[constants.PrivateIngredientKeyServiceCAConfig] = writeServerCA(t, srv) + return cfg +} diff --git a/internal/runbits/orgkey/https.go b/internal/runbits/orgkey/https.go new file mode 100644 index 0000000000..b24913d80a --- /dev/null +++ b/internal/runbits/orgkey/https.go @@ -0,0 +1,216 @@ +package orgkey + +import ( + "context" + "crypto/tls" + "crypto/x509" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/errs" + "github.com/ActiveState/cli/internal/logging" +) + +const ( + // fetchTimeout bounds a single key-service request. + fetchTimeout = 15 * time.Second + // maxResponseBytes caps the contract response read from the key service. + maxResponseBytes = 1 << 20 // 1 MiB +) + +// provider fetches the org key over HTTPS and caches it in memory for the run. +type provider struct { + cfg configurable + owner string + + mu sync.Mutex + done bool + key []byte + keyID string + err error +} + +// New returns a Provider that reads its key-service configuration from cfg and +// validates the fetched key against owner (the project's organization). +func New(cfg configurable, owner string) Provider { + return &provider{cfg: cfg, owner: owner} +} + +func (p *provider) Configured() bool { + return p.cfg.GetString(constants.PrivateIngredientKeyServiceURLConfig) != "" +} + +// Key fetches and validates the org key on first call and returns the cached +// result (including a cached error) on every subsequent call in the run. +func (p *provider) Key(ctx context.Context) ([]byte, string, error) { + p.mu.Lock() + defer p.mu.Unlock() + if p.done { + return p.key, p.keyID, p.err + } + p.done = true + p.key, p.keyID, p.err = p.load(ctx) + return p.key, p.keyID, p.err +} + +func (p *provider) Close() { + p.mu.Lock() + defer p.mu.Unlock() + for i := range p.key { + p.key[i] = 0 + } + p.key = nil +} + +func (p *provider) load(ctx context.Context) (key []byte, keyID string, err error) { + if !p.Configured() { + return nil, "", ErrNotConfigured + } + + if p.diskCacheEnabled() { + if raw, ok := p.readDiskCache(); ok { + if key, keyID, err := validateContract(raw, p.owner); err == nil { + return key, keyID, nil + } else { + logging.Warning("Ignoring invalid on-disk org key cache: %v", errs.JoinMessage(err)) + } + } + } + + raw, err := p.fetch(ctx) + if err != nil { + return nil, "", errs.Wrap(err, "unable to fetch org key") + } + key, keyID, err = validateContract(raw, p.owner) + if err != nil { + return nil, "", errs.Wrap(err, "unable to validate org key") + } + + if p.diskCacheEnabled() { + if werr := p.writeDiskCache(raw); werr != nil { + logging.Warning("Could not cache org key on disk: %v", errs.JoinMessage(werr)) + } + } + return key, keyID, nil +} + +// fetch performs the HTTPS GET against the configured key service and returns +// the raw contract body. +func (p *provider) fetch(ctx context.Context) ([]byte, error) { + base := p.cfg.GetString(constants.PrivateIngredientKeyServiceURLConfig) + u, err := url.Parse(base) + if err != nil { + return nil, errs.Wrap(err, "unable to parse key service URL") + } + if u.Scheme != "https" { + return nil, ErrInsecureURL + } + u.Path = strings.TrimRight(u.Path, "/") + endpointPath + + client, err := p.httpClient() + if err != nil { + return nil, errs.Wrap(err, "unable to build key service HTTP client") + } + + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, errs.Wrap(err, "unable to build key service request") + } + if err := p.applyAuth(req); err != nil { + return nil, errs.Wrap(err, "unable to apply key service authentication") + } + + resp, err := client.Do(req) + if err != nil { + return nil, errs.Wrap(err, "unable to reach key service") + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errs.New("key service returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, errs.Wrap(err, "unable to read key service response") + } + return body, nil +} + +// httpClient builds an HTTPS client enforcing TLS 1.2+, the configured CA or +// pinned certificate, optional mTLS, and a refusal to follow redirects (the URL +// is pinned). +func (p *provider) httpClient() (*http.Client, error) { + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS12} + + if caPath := p.cfg.GetString(constants.PrivateIngredientKeyServiceCAConfig); caPath != "" { + pem, err := os.ReadFile(caPath) + if err != nil { + return nil, errs.Wrap(err, "unable to read key service CA file") + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, errs.New("key service CA file contains no valid certificates") + } + tlsCfg.RootCAs = pool + } + + certPath := p.cfg.GetString(constants.PrivateIngredientMTLSCertConfig) + keyPath := p.cfg.GetString(constants.PrivateIngredientMTLSKeyConfig) + if certPath != "" || keyPath != "" { + if certPath == "" || keyPath == "" { + return nil, errs.New("mTLS requires both a client certificate and a client key") + } + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, errs.Wrap(err, "unable to load mTLS client certificate") + } + tlsCfg.Certificates = []tls.Certificate{cert} + } + + return &http.Client{ + Timeout: fetchTimeout, + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return errs.New("key service redirects are not allowed") + }, + }, nil +} + +// applyAuth attaches a bearer token to the request when one is configured. mTLS +// (if configured) is applied at the transport layer in httpClient. +func (p *provider) applyAuth(req *http.Request) error { + token, err := p.bearerToken() + if err != nil { + return errs.Wrap(err, "unable to read bearer token") + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return nil +} + +// bearerToken reads the short-lived bearer token from the configured env var or +// file. It never returns the token in an error. +func (p *provider) bearerToken() (string, error) { + if envName := p.cfg.GetString(constants.PrivateIngredientBearerTokenEnvConfig); envName != "" { + return strings.TrimSpace(os.Getenv(envName)), nil + } + if path := p.cfg.GetString(constants.PrivateIngredientBearerTokenFileConfig); path != "" { + b, err := os.ReadFile(path) + if err != nil { + return "", errs.Wrap(err, "unable to read bearer token file") + } + return strings.TrimSpace(string(b)), nil + } + return "", nil +} diff --git a/internal/runbits/orgkey/https_test.go b/internal/runbits/orgkey/https_test.go new file mode 100644 index 0000000000..b5fbd9d86a --- /dev/null +++ b/internal/runbits/orgkey/https_test.go @@ -0,0 +1,253 @@ +package orgkey + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "sync/atomic" + "testing" + "time" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/constants" +) + +// keyServiceHandler serves a valid contract at endpointPath and counts requests. +// When expectToken is non-empty, it requires a matching bearer token. +func keyServiceHandler(t *testing.T, key []byte, org, keyID, expectToken string, fetches *atomic.Int32) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + fetches.Add(1) + if r.URL.Path != endpointPath { + w.WriteHeader(http.StatusNotFound) + return + } + if expectToken != "" && r.Header.Get("Authorization") != "Bearer "+expectToken { + w.WriteHeader(http.StatusUnauthorized) + return + } + _, _ = w.Write(mustJSON(t, contractFields(key, org, keyID))) + } +} + +func TestKeyHappyPathAndSingleFetch(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid-1", "secret-token", &fetches)) + defer srv.Close() + + cfg := configForServer(t, srv) + cfg.strings[constants.PrivateIngredientBearerTokenEnvConfig] = "ORGKEY_TOKEN" + t.Setenv("ORGKEY_TOKEN", "secret-token") + + p := New(cfg, "myorg") + defer p.Close() + + gotKey, gotID, err := p.Key(context.Background()) + if err != nil { + t.Fatalf("Key: %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("returned key does not match") + } + if gotID != "kid-1" { + t.Errorf("keyID = %q, want kid-1", gotID) + } + + // Subsequent calls reuse the in-run cache: still exactly one fetch. + for i := 0; i < 3; i++ { + if _, _, err := p.Key(context.Background()); err != nil { + t.Fatalf("Key (cached): %v", err) + } + } + if got := fetches.Load(); got != 1 { + t.Errorf("fetch count = %d, want exactly 1", got) + } +} + +func TestKeyBearerTokenFromFile(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid", "file-token", &fetches)) + defer srv.Close() + + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte("file-token\n"), 0600); err != nil { // trailing newline is trimmed + t.Fatal(err) + } + cfg := configForServer(t, srv) + cfg.strings[constants.PrivateIngredientBearerTokenFileConfig] = tokenFile + + gotKey, _, err := New(cfg, "myorg").Key(context.Background()) + if err != nil { + t.Fatalf("Key (bearer file): %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("returned key does not match") + } +} + +func TestKeyRefusesNonHTTPS(t *testing.T) { + cfg := newFakeConfig(t) + cfg.strings[constants.PrivateIngredientKeyServiceURLConfig] = "http://insecure.example.com" + + _, _, err := New(cfg, "myorg").Key(context.Background()) + if !errors.Is(err, ErrInsecureURL) { + t.Fatalf("error = %v, want ErrInsecureURL", err) + } +} + +func TestKeyRejectsUntrustedCertificate(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid", "", &fetches)) + defer srv.Close() + + // Point at the server but do NOT configure its CA, so its self-signed cert + // is untrusted. + cfg := newFakeConfig(t) + cfg.strings[constants.PrivateIngredientKeyServiceURLConfig] = srv.URL + + if _, _, err := New(cfg, "myorg").Key(context.Background()); err == nil { + t.Fatal("expected a TLS verification error for an untrusted certificate") + } +} + +func TestKeyFailsClosedOnTimeout(t *testing.T) { + key := testKey() + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(500 * time.Millisecond) + _, _ = w.Write(mustJSON(t, contractFields(key, "myorg", "kid"))) + })) + defer srv.Close() + cfg := configForServer(t, srv) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + if _, _, err := New(cfg, "myorg").Key(ctx); err == nil { + t.Fatal("expected a timeout error, got nil") + } +} + +func TestKeyMTLS(t *testing.T) { + key := testKey() + certPath, keyPath := genClientCert(t) + + var sawClientCert bool + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawClientCert = len(r.TLS.PeerCertificates) > 0 + _, _ = w.Write(mustJSON(t, contractFields(key, "myorg", "kid"))) + })) + srv.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert} + srv.StartTLS() + defer srv.Close() + + cfg := configForServer(t, srv) + cfg.strings[constants.PrivateIngredientMTLSCertConfig] = certPath + cfg.strings[constants.PrivateIngredientMTLSKeyConfig] = keyPath + + gotKey, _, err := New(cfg, "myorg").Key(context.Background()) + if err != nil { + t.Fatalf("Key (mTLS): %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("returned key does not match") + } + if !sawClientCert { + t.Error("server did not receive a client certificate") + } +} + +func TestKeyRejectsTLSBelow12(t *testing.T) { + key := testKey() + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(mustJSON(t, contractFields(key, "myorg", "kid"))) + })) + srv.TLS = &tls.Config{MaxVersion: tls.VersionTLS11} + srv.StartTLS() + defer srv.Close() + cfg := configForServer(t, srv) + + if _, _, err := New(cfg, "myorg").Key(context.Background()); err == nil { + t.Fatal("expected handshake failure against a TLS 1.1 server") + } +} + +func TestNotConfiguredIsNoOp(t *testing.T) { + cfg := newFakeConfig(t) // no URL set + p := New(cfg, "myorg") + if p.Configured() { + t.Error("Configured() = true with no URL set") + } + if _, _, err := p.Key(context.Background()); !errors.Is(err, ErrNotConfigured) { + t.Fatalf("error = %v, want ErrNotConfigured", err) + } +} + +func TestOnDiskCacheReusedAcrossRuns(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid", "", &fetches)) + defer srv.Close() + + cfg := configForServer(t, srv) + cfg.bools[constants.PrivateIngredientCacheKeyConfig] = true + + // First run fetches over the network and writes the cache. + if _, _, err := New(cfg, "myorg").Key(context.Background()); err != nil { + t.Fatalf("first Key: %v", err) + } + if got := fetches.Load(); got != 1 { + t.Fatalf("fetch count after first run = %d, want 1", got) + } + + cachePath := filepath.Join(cfg.dir, cacheFileName) + info, err := os.Stat(cachePath) + if err != nil { + t.Fatalf("cache file not written: %v", err) + } + if runtime.GOOS != "windows" && info.Mode()&0177 != 0 { + t.Errorf("cache file mode = %v, want 0600", info.Mode()) + } + + // Second run (fresh provider, same config dir) reads from disk: no new fetch. + gotKey, _, err := New(cfg, "myorg").Key(context.Background()) + if err != nil { + t.Fatalf("second Key: %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("cached key does not match") + } + if got := fetches.Load(); got != 1 { + t.Errorf("fetch count after cached run = %d, want still 1", got) + } +} + +func TestMemoryOnlyWritesNothingToDisk(t *testing.T) { + key := testKey() + var fetches atomic.Int32 + srv := httptest.NewTLSServer(keyServiceHandler(t, key, "myorg", "kid", "", &fetches)) + defer srv.Close() + + cfg := configForServer(t, srv) // cache opt-in left false (default) + + if _, _, err := New(cfg, "myorg").Key(context.Background()); err != nil { + t.Fatalf("Key: %v", err) + } + if _, err := os.Stat(filepath.Join(cfg.dir, cacheFileName)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("cache file should not exist in memory-only mode (stat err = %v)", err) + } +} + +// sanity: artifactcrypto.KeySize is what the contract helpers assume. +func TestKeySizeAssumption(t *testing.T) { + if artifactcrypto.KeySize != 32 { + t.Fatalf("unexpected KeySize %d", artifactcrypto.KeySize) + } +} diff --git a/internal/runbits/orgkey/orgkey.go b/internal/runbits/orgkey/orgkey.go new file mode 100644 index 0000000000..120fa6591e --- /dev/null +++ b/internal/runbits/orgkey/orgkey.go @@ -0,0 +1,122 @@ +// Package orgkey fetches and validates an organization's single AES-256 +// encryption key from the customer-hosted HTTPS key service, caches it for the +// duration of a run, and hands the raw key bytes to the artifactcrypto +// primitives. The key is read only from the customer's own service and is never +// placed in a request to the ActiveState Platform. +// +// The custody backend lives caller-side (it makes network calls and reads +// config). +package orgkey + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/constants" + "github.com/ActiveState/cli/internal/errs" + configMediator "github.com/ActiveState/cli/internal/mediators/config" +) + +func init() { + configMediator.RegisterOption(constants.PrivateIngredientKeyServiceURLConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientKeyServiceCAConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientMTLSCertConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientMTLSKeyConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientBearerTokenEnvConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientBearerTokenFileConfig, configMediator.String, "") + configMediator.RegisterOption(constants.PrivateIngredientCacheKeyConfig, configMediator.Bool, false) +} + +var ( + // ErrNotConfigured indicates no key-service URL has been configured. + ErrNotConfigured = errs.New("org key service is not configured") + // ErrInsecureURL indicates the configured key-service URL is not https. + ErrInsecureURL = errs.New("org key service URL must use https") + // ErrUnknownSchema indicates the contract's schema field is not recognized. + ErrUnknownSchema = errs.New("org key contract has an unrecognized schema") + // ErrOrgMismatch indicates the contract is for a different organization than the project's. + ErrOrgMismatch = errs.New("org key does not belong to this project's organization") + // ErrBadAlgorithm indicates the contract specifies an unsupported algorithm. + ErrBadAlgorithm = errs.New("org key contract specifies an unsupported algorithm") + // ErrBadEncoding indicates the contract's key encoding is unsupported or the key is not valid base64. + ErrBadEncoding = errs.New("org key contract specifies an unsupported or invalid key encoding") + // ErrBadKeyLength indicates the decoded key is not a 32-byte AES-256 key. + ErrBadKeyLength = errs.New("org key must be 32 bytes (AES-256)") + // ErrFingerprintMismatch indicates the decoded key does not match its stated fingerprint. + ErrFingerprintMismatch = errs.New("org key does not match its stated fingerprint") +) + +const ( + contractSchema = "activestate.pim.orgkey/v1" + contractAlgorithm = "AES-256-GCM" + contractEncoding = "base64" + // endpointPath is appended to the configured base URL to form the request URL. + endpointPath = "/v1/org-key" +) + +// configurable is the subset of the config instance this package reads. +type configurable interface { + GetString(key string) string + GetBool(key string) bool + ConfigPath() string +} + +// Provider supplies the organization's AES-256 key for a run. Implementations +// fetch and validate the key on first use and return the cached value +// thereafter; the at-rest backend is swappable behind this interface. +type Provider interface { + // Configured reports whether a key service has been configured. When it + // returns false the provider is a no-op and Key returns ErrNotConfigured. + Configured() bool + // Key returns the raw 32-byte org key and its id for this run. + Key(ctx context.Context) (key []byte, keyID string, err error) + // Close zeroizes any in-memory key material held by the provider. + Close() +} + +// contract is the org-key JSON document served by the key service. +type contract struct { + Schema string `json:"schema"` + Org string `json:"org"` + KeyID string `json:"key_id"` + Algorithm string `json:"algorithm"` + Encoding string `json:"encoding"` + Key string `json:"key"` + Fingerprint string `json:"fingerprint"` +} + +// validateContract parses raw, checks it against the expected organization, and +// returns the decoded 32-byte key and its id. Errors never include the key +// bytes. +func validateContract(raw []byte, expectedOrg string) (key []byte, keyID string, err error) { + var c contract + if err := json.Unmarshal(raw, &c); err != nil { + return nil, "", errs.Wrap(err, "unable to parse org key contract") + } + if c.Schema != contractSchema { + return nil, "", ErrUnknownSchema + } + if !strings.EqualFold(c.Org, expectedOrg) { + return nil, "", ErrOrgMismatch + } + if c.Algorithm != contractAlgorithm { + return nil, "", ErrBadAlgorithm + } + if c.Encoding != contractEncoding { + return nil, "", ErrBadEncoding + } + key, decErr := base64.StdEncoding.DecodeString(strings.TrimPrefix(c.Key, "b64:")) + if decErr != nil { + return nil, "", ErrBadEncoding + } + if len(key) != artifactcrypto.KeySize { + return nil, "", ErrBadKeyLength + } + if artifactcrypto.Fingerprint(key) != c.Fingerprint { + return nil, "", ErrFingerprintMismatch + } + return key, c.KeyID, nil +} diff --git a/internal/runbits/orgkey/preflight.go b/internal/runbits/orgkey/preflight.go new file mode 100644 index 0000000000..87cc25960f --- /dev/null +++ b/internal/runbits/orgkey/preflight.go @@ -0,0 +1,23 @@ +package orgkey + +import ( + "io" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/errs" +) + +// PreflightKey confirms that key matches the artifact whose encrypted payload +// begins at r, checking the payload header's fingerprint before any body is +// read, so publish can fail before upload and pull can fail before a body +// transfer. A mismatch returns an error matching artifactcrypto.ErrWrongKey. +func PreflightKey(r io.Reader, key []byte) error { + header, err := artifactcrypto.ParseHeader(r) + if err != nil { + return errs.Wrap(err, "unable to read artifact header") + } + if err := header.CheckKey(key); err != nil { + return errs.Wrap(err, "key does not match artifact") + } + return nil +} diff --git a/internal/runbits/orgkey/validate_test.go b/internal/runbits/orgkey/validate_test.go new file mode 100644 index 0000000000..ddbeb50318 --- /dev/null +++ b/internal/runbits/orgkey/validate_test.go @@ -0,0 +1,109 @@ +package orgkey + +import ( + "bytes" + "encoding/base64" + "errors" + "strings" + "testing" + + "github.com/ActiveState/cli/internal/artifactcrypto" + "github.com/ActiveState/cli/internal/constants" +) + +func TestValidateContract(t *testing.T) { + key := testKey() + + tests := []struct { + name string + mutate func(m map[string]string) + wantErr error // nil means success + }{ + {name: "valid", mutate: func(map[string]string) {}}, + {name: "unknown schema", mutate: func(m map[string]string) { m["schema"] = "something/v9" }, wantErr: ErrUnknownSchema}, + {name: "org mismatch", mutate: func(m map[string]string) { m["org"] = "someoneelse" }, wantErr: ErrOrgMismatch}, + {name: "bad algorithm", mutate: func(m map[string]string) { m["algorithm"] = "AES-128-GCM" }, wantErr: ErrBadAlgorithm}, + {name: "bad encoding", mutate: func(m map[string]string) { m["encoding"] = "hex" }, wantErr: ErrBadEncoding}, + {name: "invalid base64", mutate: func(m map[string]string) { m["key"] = "b64:!!!notbase64!!!" }, wantErr: ErrBadEncoding}, + {name: "wrong key length", mutate: func(m map[string]string) { + m["key"] = "b64:" + base64.StdEncoding.EncodeToString([]byte("too short")) + }, wantErr: ErrBadKeyLength}, + {name: "fingerprint mismatch", mutate: func(m map[string]string) { m["fingerprint"] = "sha256:deadbeef" }, wantErr: ErrFingerprintMismatch}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fields := contractFields(key, "myorg", "kid-1") + tc.mutate(fields) + gotKey, gotID, err := validateContract(mustJSON(t, fields), "myorg") + + if tc.wantErr != nil { + if !errors.Is(err, tc.wantErr) { + t.Fatalf("error = %v, want %v", err, tc.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !bytes.Equal(gotKey, key) { + t.Error("decoded key does not match") + } + if gotID != "kid-1" { + t.Errorf("keyID = %q, want kid-1", gotID) + } + }) + } +} + +func TestValidateContractOrgIsCaseInsensitive(t *testing.T) { + key := testKey() + fields := contractFields(key, "MyOrg", "kid") + if _, _, err := validateContract(mustJSON(t, fields), "myorg"); err != nil { + t.Fatalf("expected case-insensitive org match, got %v", err) + } +} + +func TestValidateContractRejectsGarbageJSON(t *testing.T) { + if _, _, err := validateContract([]byte("not json"), "myorg"); err == nil { + t.Fatal("expected an error for non-JSON contract") + } +} + +func TestPreflightKey(t *testing.T) { + key := testKey() + other := make([]byte, artifactcrypto.KeySize) // all zeros, different key + + var payload bytes.Buffer + if err := artifactcrypto.Encrypt(strings.NewReader("private wheel"), &payload, key, "kid"); err != nil { + t.Fatal(err) + } + + if err := PreflightKey(bytes.NewReader(payload.Bytes()), key); err != nil { + t.Errorf("PreflightKey(correct key) = %v, want nil", err) + } + if err := PreflightKey(bytes.NewReader(payload.Bytes()), other); !errors.Is(err, artifactcrypto.ErrWrongKey) { + t.Errorf("PreflightKey(wrong key) = %v, want ErrWrongKey", err) + } +} + +func TestSanitizeChildEnv(t *testing.T) { + cfg := newFakeConfig(t) + cfg.strings[constants.PrivateIngredientBearerTokenEnvConfig] = "ORGKEY_TOKEN" + + env := map[string]string{"ORGKEY_TOKEN": "secret", "PATH": "/usr/bin"} + SanitizeChildEnv(cfg, env) + if _, ok := env["ORGKEY_TOKEN"]; ok { + t.Error("bearer-token env var was not scrubbed") + } + if env["PATH"] != "/usr/bin" { + t.Error("unrelated env var was removed") + } + + // No configured token var: nothing is removed. + env2 := map[string]string{"PATH": "/usr/bin"} + SanitizeChildEnv(newFakeConfig(t), env2) + if len(env2) != 1 { + t.Error("env modified when no token var configured") + } +} diff --git a/internal/subshell/subshell.go b/internal/subshell/subshell.go index f547a1b865..dc13ce32f7 100644 --- a/internal/subshell/subshell.go +++ b/internal/subshell/subshell.go @@ -20,6 +20,7 @@ import ( "github.com/ActiveState/cli/internal/osutils" "github.com/ActiveState/cli/internal/output" "github.com/ActiveState/cli/internal/rollbar" + "github.com/ActiveState/cli/internal/runbits/orgkey" "github.com/ActiveState/cli/internal/subshell/bash" "github.com/ActiveState/cli/internal/subshell/cmd" "github.com/ActiveState/cli/internal/subshell/fish" @@ -123,7 +124,10 @@ func New(cfg sscommon.Configurable) SubShell { logging.Debug("Using binary: %s", path) subs.SetBinary(path) - err := subs.SetEnv(osutils.EnvSliceToMap(os.Environ())) + env := osutils.EnvSliceToMap(os.Environ()) + orgkey.SanitizeChildEnv(cfg, env) + + err := subs.SetEnv(env) if err != nil { // We cannot error here, but this error will resurface when activating a runtime, so we can // notify the user at that point. diff --git a/pkg/runtime/options.go b/pkg/runtime/options.go index 95c2e0bf53..63431b74a7 100644 --- a/pkg/runtime/options.go +++ b/pkg/runtime/options.go @@ -15,6 +15,15 @@ func WithAuthToken(token string) SetOpt { return func(opts *Opts) { opts.AuthToken = token } } +// WithDecryptionKey supplies the organization AES-256 key (and its id) used to +// decrypt private artifacts during install. +func WithDecryptionKey(key []byte, keyID string) SetOpt { + return func(opts *Opts) { + opts.OrgKey = key + opts.OrgKeyID = keyID + } +} + func WithBuildlogFilePath(path string) SetOpt { return func(opts *Opts) { opts.BuildlogFilePath = path } } diff --git a/pkg/runtime/setup.go b/pkg/runtime/setup.go index 7c6e210ae6..9c9a41c829 100644 --- a/pkg/runtime/setup.go +++ b/pkg/runtime/setup.go @@ -54,6 +54,12 @@ type Opts struct { // the server can authorize the stream. Empty for unauthenticated callers. AuthToken string + // OrgKey is the organization AES-256 key used to decrypt private artifacts + // during install, with OrgKeyID identifying which key it is. Both are empty + // when the runtime has no private ingredients. + OrgKey []byte + OrgKeyID string + FromArchive *fromArchive // Annotations are used strictly to pass information for the purposes of analytics