Skip to content
Open
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
28 changes: 28 additions & 0 deletions internal/constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
51 changes: 51 additions & 0 deletions internal/runbits/orgkey/cache.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions internal/runbits/orgkey/cache_lin_mac.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions internal/runbits/orgkey/cache_win.go
Original file line number Diff line number Diff line change
@@ -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
}
16 changes: 16 additions & 0 deletions internal/runbits/orgkey/env.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
131 changes: 131 additions & 0 deletions internal/runbits/orgkey/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading