Skip to content
94 changes: 94 additions & 0 deletions candidtest/candidtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,17 @@ package candidtest

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"net/http"
"net/http/httptest"
"time"

"github.com/go-macaroon-bakery/macaroon-bakery/v3/bakery"
"github.com/juju/aclstore/v2"
Expand Down Expand Up @@ -135,3 +145,87 @@ func AddIdentity(ctx context.Context, st store.Store, identity *store.Identity)
panic(err)
}
}

// GenerateTestCert generates a self-signed test certificate and returns
// the certificate, certificate PEM, and key PEM.
func GenerateTestCert(commonName string) (tls.Certificate, []byte, []byte, error) {
// Generate private key
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, nil, nil, err
}

// Create certificate
now := time.Now()
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: commonName,
},
NotBefore: now,
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
DNSNames: []string{commonName, "localhost"},
}

// Self-sign the certificate
certBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return tls.Certificate{}, nil, nil, err
}

// Encode certificate to PEM
certPEM := pem.EncodeToMemory(&pem.Block{
Type: "CERTIFICATE",
Bytes: certBytes,
})

// Encode private key to PEM
privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
if err != nil {
return tls.Certificate{}, nil, nil, err
}

keyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PRIVATE KEY",
Bytes: privateKeyBytes,
})

// Create tls.Certificate
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return tls.Certificate{}, nil, nil, err
}

return cert, certPEM, keyPEM, nil
}

// NewTLSServerWithConfig creates a new TLS HTTPS server with the given handler,
// TLS configuration, and certificate/key PEM data.
func NewTLSServerWithConfig(handler http.Handler, tlsConfig *tls.Config, certPEM, keyPEM []byte) *httptest.Server {
Comment thread
tmerten marked this conversation as resolved.
srv := httptest.NewUnstartedServer(handler)

// Parse the certificate
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
panic(err)
}

// Ensure the TLS config has the certificate
if tlsConfig.Certificates == nil {
tlsConfig.Certificates = []tls.Certificate{cert}
}

// Enable HTTP/2 by setting NextProtos
if tlsConfig.NextProtos == nil {
tlsConfig.NextProtos = []string{"h2", "http/1.1"}
}

srv.TLS = tlsConfig
srv.StartTLS()
return srv
}
23 changes: 23 additions & 0 deletions cmd/candidsrv/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ func serve(conf *config.Config) error {
})
}

const (
hstsMaxAgeFormat = "max-age=%d"
hstsIncludeSubDomains = "; includeSubDomains"
)

// hstsMiddleware adds HSTS headers when configured.
func hstsMiddleware(next http.Handler, maxAge int, includeSubDomains bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
headerParams := fmt.Sprintf(hstsMaxAgeFormat, maxAge)
if includeSubDomains {
// Capital 'S and D' per RFC 6797
headerParams += hstsIncludeSubDomains
}
w.Header().Add("Strict-Transport-Security", headerParams)
next.ServeHTTP(w, r)
})
}

func serveIdentity(conf *config.Config, params candid.ServerParams) error {
logger.Infof("setting up the identity server")
params.IdentityProviders = defaultIDPs
Expand Down Expand Up @@ -172,6 +190,11 @@ func serveIdentity(conf *config.Config, params candid.ServerParams) error {
// optionally wrapped by the logging handler below.
var server http.Handler = srv

// Add HSTS middleware if configured.
if conf.HSTSMaxAge > 0 {
server = hstsMiddleware(server, conf.HSTSMaxAge, conf.HSTSIncludeSubdomains)
}

if conf.AccessLog != "" {
accesslog := &lumberjack.Logger{
Filename: conf.AccessLog,
Expand Down
62 changes: 55 additions & 7 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ type Config struct {
TLSCert string `yaml:"tls-cert"`
TLSKey string `yaml:"tls-key"`

// HSTSMaxAge holds the max-age value for HSTS headers in seconds.
// If 0, HSTS headers will not be added. Typically set to 31536000 (1 year).
HSTSMaxAge int `yaml:"HSTS-max-age"`

// HSTSIncludeSubdomains controls whether the includeSubDomains directive
// is added to the HSTS header. If this is true, HSTSMaxAge must be
// greater than 0.
HSTSIncludeSubdomains bool `yaml:"HSTS-include-subdomains"`

// TLSCipherSuites holds a list of enabled TLS cipher suites.
// If empty, Go's default secure cipher suites are used.
// Values should be standard cipher suite names (e.g., "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256").
TLSCipherSuites []string `yaml:"TLS-cipher-suites"`

// PublicKey and PrivateKey holds the key pair used by the Candid
// server for encryption and decryption of third party caveats.
// These must be specified.
Expand Down Expand Up @@ -139,8 +153,30 @@ type Config struct {
BrandLogoLocation string `yaml:"brand-logo-location"`
}

// TLSConfig returns a TLS configuration to be used for serving
// the API. If the TLS certficate and key are not specified, it returns nil.
func parseCipherSuites(names []string) ([]uint16, error) {
// this list is inspired by Golang's current cypher suite prioritization:
// https://cs.opensource.google/go/go/+/refs/tags/go1.25.4:src/crypto/tls/cipher_suites.go
var suites []uint16

for _, name := range names {
var cipherSuiteSupported = false
for _, cs := range tls.CipherSuites() {
if cs.Name == name {
suites = append(suites, cs.ID)
cipherSuiteSupported = true
}
}
if !cipherSuiteSupported {
return nil, errgo.Newf("Unsupported cipher suite: %s", name)
}
}
return suites, nil
}

// TLSConfig returns a TLS configuration to be used for serving the API.
// If the TLS certificate and key are not specified, it returns nil.
// If tls-cipher-suites are configured they will be used.
// If tls-cipher-suites are not configured the Golang defaults are used.
func (c *Config) TLSConfig() *tls.Config {
if c.TLSCert == "" || c.TLSKey == "" {
return nil
Expand All @@ -151,12 +187,21 @@ func (c *Config) TLSConfig() *tls.Config {
logger.Errorf("cannot create certificate: %s", err)
return nil
}
return &tls.Config{
Certificates: []tls.Certificate{
cert,
},
MinVersion: tls.VersionTLS12,

tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
}

if len(c.TLSCipherSuites) > 0 {
cipherSuites, err := parseCipherSuites(c.TLSCipherSuites)
if err != nil {
logger.Errorf("cannot parse cipher suites: %s", err)
return nil
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are you sure we don't want to return an error here? might be surprising to some that they set a set cipher suites that then isn't used, because one of them was misspelled. i propose returning an error so that the user can deal with it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO introducing an error here would mean introducing it above as well (when the certificate is created). I did not want to change too much of the existing code which is why I followed the pattern of returning nil in that case. That said, I'd like to get it going as is first. But I'm happy to follow up with something later.

}
tlsConfig.CipherSuites = cipherSuites
}
return tlsConfig
}

func (c *Config) validate() error {
Expand All @@ -168,6 +213,9 @@ func (c *Config) validate() error {
if c.ListenAddress == "" {
missing = append(missing, "listen-address")
}
if c.HSTSIncludeSubdomains && c.HSTSMaxAge == 0 {
missing = append(missing, "HSTS-max-age (required when HSTS-include-subdomains is true)")
}
if c.PrivateKey == nil {
missing = append(missing, "private-key")
}
Expand Down
130 changes: 118 additions & 12 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,99 @@ tls-key: |
ORfedwfVln37uivduCeyBLMhaYWiW6CN4Di/d8LsI1hwe1MlNHuV2EptaFDzfjx8
FWQQKAkL5KolhJye0Kz/X8CT3UMmhOK73UkUaOvMvdSjxLFgIruxWQ==
-----END RSA PRIVATE KEY-----
HSTS-max-age: 31536000
HSTS-include-subdomains: true
TLS-cipher-suites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
resource-path: /resources
http-proxy: http://proxy.example.com:3128
no-proxy: localhost,.example.com
redirect-login-trusted-urls:
- https://example.com/1
- https://example.com/2
redirect-login-trusted-domains:
- www.example.com
- "*.example.net"
api-macaroon-timeout: 2h
discharge-macaroon-timeout: 24h
discharge-token-timeout: 6h
enable-email-login: true
`

const testConfigWithUnsupportedCipher = `
listen-address: 1.2.3.4:5678
foo: 1
bar: false
admin-password: mypasswd
private-key: 8PjzjakvIlh3BVFKe8axinRDutF6EDIfjtuf4+JaNow=
public-key: CIdWcEUN+0OZnKW9KwruRQnQDY/qqzVdD30CijwiWCk=
admin-agent-public-key: dUnC8p9p3nygtE2h92a47Ooq0rXg0fVSm3YBWou5/UQ=
location: http://foo.com:1234
storage:
type: test
attribute: hello
rendezvous-timeout: 1m
identity-providers:
- type: usso
- type: keystone
name: ks1
url: http://example.com/keystone
private-addr: localhost
tls-cert: |
-----BEGIN CERTIFICATE-----
MIIDLDCCAhQCCQDVXrWn1thP6DANBgkqhkiG9w0BAQsFADBYMQswCQYDVQQGEwJH
QjENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDENMAsGA1UECgwEVGVzdDEN
MAsGA1UECwwEVGVzdDENMAsGA1UEAwwEVGVzdDAeFw0xNjA3MDcxMjE2MDBaFw0z
NjA3MDIxMjE2MDBaMFgxCzAJBgNVBAYTAkdCMQ0wCwYDVQQIDARUZXN0MQ0wCwYD
VQQHDARUZXN0MQ0wCwYDVQQKDARUZXN0MQ0wCwYDVQQLDARUZXN0MQ0wCwYDVQQD
DARUZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3uRyzTaYMWj/
aGjqQtCMf4VMLIcR4o+yJVUp7CvhHIa/Ykx32OZMLth6DihykYzOFZj9wzD2a+GB
8P3RkDMP5dxQF9yQSTTl/Ec7ZkHHnJzpao9mGsfJ7h24F4XTKC7QovaNw5HV83ej
Vwrose8BHe5UlEpncTIqOY3JJbzzkrzSMzS7cGB1l55zXpDQVcRzv/182qFX2L3+
ukIlbt3PNAjGPgKWYeVameTL38oKjJ5ftrADWjAWc7IBPw65KvqOTj5Jw+Jhkj4H
4kkXKKn8N6ItiWclpWuKi8Va36VVUXnqPxOWnIK4AGnO8WEArRhU7XK+EiFK8TuH
SSrOh9myWQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBvuGuwGrMSHNOKrWrWnwKD
T3Ge9FfUonBmkzvGmWHfLROju3mwxAP0lB10+sn1gnjUHzKiVjeY8fuAjFQMrKUp
HUWCaVPjsExd2OYu+6f+06rTrP98BNopuYWeIIkmc3JoFwOmSKTA5JIlNBDsN5F/
PFcXE9Xjc4Ob1ut/bv6hJ1nbgbaVSNB4Zc+3oxi2X+xBut8zqATq7JYvO0SVH6h1
oSp3lveosF9AQB8uLtWZFf3wnburr2zG6UhkSwQdy0GYEwTtqaYs7Ue7bHvO0GYG
zPCVixoo4QoTiwDV7HGodrjvcMgtUgoDOhR6daZPEYV6rQJJoGhMF5+UAgS2KiMh
-----END CERTIFICATE-----
tls-key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA3uRyzTaYMWj/aGjqQtCMf4VMLIcR4o+yJVUp7CvhHIa/Ykx3
2OZMLth6DihykYzOFZj9wzD2a+GB8P3RkDMP5dxQF9yQSTTl/Ec7ZkHHnJzpao9m
GsfJ7h24F4XTKC7QovaNw5HV83ejVwrose8BHe5UlEpncTIqOY3JJbzzkrzSMzS7
cGB1l55zXpDQVcRzv/182qFX2L3+ukIlbt3PNAjGPgKWYeVameTL38oKjJ5ftrAD
WjAWc7IBPw65KvqOTj5Jw+Jhkj4H4kkXKKn8N6ItiWclpWuKi8Va36VVUXnqPxOW
nIK4AGnO8WEArRhU7XK+EiFK8TuHSSrOh9myWQIDAQABAoIBAGP7qhuvv7l6Vgep
+FucXUneq3rV5AnzV4AzoaiVTleTgko/7wrW05m39ZhgQHRV6yP5CuwCDKf78mP+
F4FNxnXfy/XINNkB56Cw+041d6sjH/ly9eRRdp1fq3KxzzSZO3G+k30E8CpUomqr
NBKNGb0pabtTXO+EBzjmBzLsfX52anGEi2U2I/Q2srU+3FAkhjb9s9ZSgWh9zgrS
0sK/oO04dlTLV0weq2oTHCX/ygQZpXvRXNJJVDRtst3R9EfUKr4YLWEK2k1PgWC+
52CJoYETbQPGiJbzReTgYTlZYHSZfuso20sPfOc01qgcJIk5qOAS2dgU5EanSQEM
0/HJ02ECgYEA+lHafm4psqi6YWLV0Evr54kzUVYXaBY/8Qbf4psCZ0o9VjfwzIPG
ncgGXhyv9qlnFx38YEKAvn/HV52J8Qi5I8k4TBtYB/GYcNvpcNgR6uMcg+nS+0nf
Y0BJgyUwY7Exh2BTIkJKLzIoOK0RKe1pk99Iboee9MDv6YaHQqlXaGUCgYEA4/ND
3jb0PTEDrCtDTYOhNqcW/ER6rq8vSwR7uiHGBY6OiYcFgmV/AC3SUpVQurw5YIxh
kQ1s7ncdBNN6fOpUEFYmBhPAkoHbVIcg98ZnzqM3tQU9o4sujT9pd8ATthAlqaBR
G+5s7Cil9RtggCBXL1G+CQPS2TJoE8Tr/SfnEOUCgYA4Dx7Ek71I4pqi9rR1qpsR
Rlu0yngBeoIlY2m+YQKfyTOFXI/T7WsMqOAsMXaC4htRRQjhMeONRiaJi6F51n9H
8WdnO/RyCvwdwlI8UFdq6CPZswLp/fhGTP5pnWmB2gwCimLz2C6u9Sem0bN3VVEA
qc+Z2UuS+qaAAP3Hww7tNQKBgQDO6gXEEzwWw4Qi5057cS2Ib5m0ufBm2oxiWxp4
danLZ4DJI7ADkl/66J0O64zRRIQMuMDjqz0jJSpJNDHua8KM5bY0M//MvWU7UEHD
x+x4rL2naq9t4awK+PGiis8Zp4SYefbGFOH4aFlkqUoqY7DgOiH3Cup8z32b3Fee
f3cGZQKBgQDvsz2cBGNFW+U03sDeHqBbdim6E2RRvPrxLkeljSiU9RzJ3P76Ousv
ORfedwfVln37uivduCeyBLMhaYWiW6CN4Di/d8LsI1hwe1MlNHuV2EptaFDzfjx8
FWQQKAkL5KolhJye0Kz/X8CT3UMmhOK73UkUaOvMvdSjxLFgIruxWQ==
-----END RSA PRIVATE KEY-----
HSTS-max-age: 31536000
HSTS-include-subdomains: true
TLS-cipher-suites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- WRONG_CIPHER_SUITE
resource-path: /resources
http-proxy: http://proxy.example.com:3128
no-proxy: localhost,.example.com
Expand Down Expand Up @@ -117,10 +210,17 @@ func TestRead(t *testing.T) {
idp.Register("usso", testIdentityProvider)
idp.Register("keystone", testIdentityProvider)
store.Register("test", testStorageBackend)

// check if wrong cipher suite names are detected
conf, _ := readConfig(c, testConfigWithUnsupportedCipher)
tlsConfig := conf.TLSConfig()
c.Assert(tlsConfig, qt.IsNil)

// continue with valid config
conf, err := readConfig(c, testConfig)
c.Assert(err, qt.IsNil)
// Check that the TLS configuration creates a valid *tls.Config
tlsConfig := conf.TLSConfig()
tlsConfig = conf.TLSConfig()
c.Assert(tlsConfig, qt.Not(qt.IsNil))
conf.TLSCert = ""
conf.TLSKey = ""
Expand Down Expand Up @@ -159,17 +259,23 @@ func TestRead(t *testing.T) {
},
},
}},
ListenAddress: "1.2.3.4:5678",
AdminPassword: "mypasswd",
PrivateKey: &key.Private,
PublicKey: &key.Public,
AdminAgentPublicKey: &adminPubKey,
Location: "http://foo.com:1234",
RendezvousTimeout: config.DurationString{Duration: time.Minute},
PrivateAddr: "localhost",
ResourcePath: "/resources",
HTTPProxy: "http://proxy.example.com:3128",
NoProxy: "localhost,.example.com",
ListenAddress: "1.2.3.4:5678",
AdminPassword: "mypasswd",
PrivateKey: &key.Private,
PublicKey: &key.Public,
AdminAgentPublicKey: &adminPubKey,
Location: "http://foo.com:1234",
RendezvousTimeout: config.DurationString{Duration: time.Minute},
HSTSMaxAge: 31536000,
HSTSIncludeSubdomains: true,
TLSCipherSuites: []string{
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
},
PrivateAddr: "localhost",
ResourcePath: "/resources",
HTTPProxy: "http://proxy.example.com:3128",
NoProxy: "localhost,.example.com",
RedirectLoginTrustedURLs: []string{
"https://example.com/1",
"https://example.com/2",
Expand Down
Loading