diff --git a/libs/go/sia/agent/agent.go b/libs/go/sia/agent/agent.go index 2c9ca264edc..ab66dbbe9de 100644 --- a/libs/go/sia/agent/agent.go +++ b/libs/go/sia/agent/agent.go @@ -556,19 +556,23 @@ func generateSshRequest(opts *sc.Options, primaryServiceName, hostname string) ( var sshCsr string var sshCertRequest *zts.SSHCertRequest if opts.Ssh && opts.Services[0].Name == primaryServiceName { + sshPrincipals := opts.SshPrincipals + // additional ssh host principals are added on best effort basis, hence error below is ignored. + additionalSshHostPrincipals, _ := opts.Provider.GetAdditionalSshHostPrincipals(opts.MetaEndPoint) + if additionalSshHostPrincipals != "" { + if sshPrincipals != "" { + sshPrincipals = sshPrincipals + "," + additionalSshHostPrincipals + } else { + sshPrincipals = additionalSshHostPrincipals + } + } if opts.SshHostKeyType == hostkey.Rsa { - sshCsr, err = util.GenerateSSHHostCSR(opts.SshPubKeyFile, opts.Domain, primaryServiceName, opts.PrivateIp, opts.ZTSCloudDomains) - } else { - sshPrincipals := opts.SshPrincipals - // additional ssh host principals are added on best effort basis, hence error below is ignored. - additionalSshHostPrincipals, _ := opts.Provider.GetAdditionalSshHostPrincipals(opts.MetaEndPoint) - if additionalSshHostPrincipals != "" { - if sshPrincipals != "" { - sshPrincipals = sshPrincipals + "," + additionalSshHostPrincipals - } else { - sshPrincipals = additionalSshHostPrincipals - } + if opts.SshIncludePrincipals { + sshCsr, err = util.GenerateSSHHostCSRWithXPrincipals(opts.SshPubKeyFile, opts.Domain, primaryServiceName, hostname, opts.PrivateIp, sshPrincipals, opts.ZTSCloudDomains) + } else { + sshCsr, err = util.GenerateSSHHostCSR(opts.SshPubKeyFile, opts.Domain, primaryServiceName, opts.PrivateIp, opts.ZTSCloudDomains) } + } else { sshCertRequest, err = util.GenerateSSHHostRequest(opts.SshPubKeyFile, opts.Domain, primaryServiceName, hostname, opts.PrivateIp, opts.InstanceId, sshPrincipals, opts.ZTSCloudDomains) } } diff --git a/libs/go/sia/agent/agent_test.go b/libs/go/sia/agent/agent_test.go index c05b593709d..aa0ddb6187e 100644 --- a/libs/go/sia/agent/agent_test.go +++ b/libs/go/sia/agent/agent_test.go @@ -20,6 +20,7 @@ import ( "crypto" "crypto/x509" "crypto/x509/pkix" + "encoding/json" "fmt" "log" "net" @@ -627,10 +628,53 @@ func TestGenerateSshRequest(test *testing.T) { opts.Domain = "athenz" opts.ZTSCloudDomains = []string{"athenz.io"} opts.SshHostKeyType = hostkey.Rsa + opts.PrivateIp = "10.11.12.13" sshReq, sshCsr, err = generateSshRequest(&opts, "api", "hostname.athenz.io") assert.Nil(test, sshReq) assert.NotEmpty(test, sshCsr) assert.Nil(test, err) + // without the ssh include principals option the csr only carries the zts cloud + // domain hostnames and the x-principals field is omitted altogether + var keyReq util.SSHKeyReq + err = json.Unmarshal([]byte(sshCsr), &keyReq) + assert.Nil(test, err) + assert.Equal(test, "host", keyReq.Certtype) + assert.Equal(test, "athenz.api", keyReq.Requser) + assert.Equal(test, "10.11.12.13", keyReq.Reqip) + assert.Equal(test, []string{"api.athenz.athenz.io"}, keyReq.Principals) + assert.Nil(test, keyReq.XPrincipals) + assert.NotContains(test, sshCsr, "xprincipals") + // ssh enabled with primary service, key type is rsa and the ssh include principals + // option enabled - the key id principals still only carry the zts cloud domain + // hostnames while the x-principals carry the hostname, the provider specific + // principals and the ip + opts.SshIncludePrincipals = true + sshReq, sshCsr, err = generateSshRequest(&opts, "api", "hostname.athenz.io") + assert.Nil(test, sshReq) + assert.Nil(test, err) + keyReq = util.SSHKeyReq{} + err = json.Unmarshal([]byte(sshCsr), &keyReq) + assert.Nil(test, err) + assert.Equal(test, "host", keyReq.Certtype) + assert.Equal(test, "athenz.api", keyReq.Requser) + assert.Equal(test, "10.11.12.13", keyReq.Reqip) + assert.Equal(test, []string{"api.athenz.athenz.io"}, keyReq.Principals) + assert.Equal(test, []string{"hostname.athenz.io", "my-vm", "my-instance-id", "10.11.12.13", "api.athenz.athenz.io"}, keyReq.XPrincipals) + // ssh enabled with primary service, key type is rsa, ssh include principals enabled + // and opts defines sshPrincipals which must be included in the x-principals along + // with the provider specific principals + opts.SshPrincipals = "cname.athenz.io" + sshReq, sshCsr, err = generateSshRequest(&opts, "api", "hostname.athenz.io") + assert.Nil(test, sshReq) + assert.Nil(test, err) + keyReq = util.SSHKeyReq{} + err = json.Unmarshal([]byte(sshCsr), &keyReq) + assert.Nil(test, err) + assert.Equal(test, []string{"api.athenz.athenz.io"}, keyReq.Principals) + assert.Equal(test, []string{"hostname.athenz.io", "cname.athenz.io", "my-vm", "my-instance-id", "10.11.12.13", "api.athenz.athenz.io"}, keyReq.XPrincipals) + opts.SshPrincipals = "" + opts.SshIncludePrincipals = false + opts.PrivateIp = "" // ssh enabled with primary service and key type is ecdsa - empty csr but not-nil cert request opts.SshHostKeyType = hostkey.Ecdsa sshReq, sshCsr, err = generateSshRequest(&opts, "api", "hostname.athenz.io") diff --git a/libs/go/sia/config/config.go b/libs/go/sia/config/config.go index e7ce1563dea..a5095e79f73 100644 --- a/libs/go/sia/config/config.go +++ b/libs/go/sia/config/config.go @@ -215,6 +215,7 @@ type Options struct { FileDirectUpdate bool //update key/cert files directly instead of using rename HostnameSuffix string //hostname suffix in case we need to auto-generate hostname SshPrincipals string //ssh additional principals + SshIncludePrincipals bool //optional flag to include additional principals in host rsa key certs AccessManagement bool //access management support ZTSCloudDomains []string //list of domain prefixes for sanDNS entries AddlSanDNSEntries []string //additional san dns entries to be added to the CSR diff --git a/libs/go/sia/util/util.go b/libs/go/sia/util/util.go index 722c11634eb..861911bba0f 100644 --- a/libs/go/sia/util/util.go +++ b/libs/go/sia/util/util.go @@ -100,14 +100,15 @@ type RoleCertReqOptions struct { // SSHKeyReq - congruent with certsign-rdl/certsign.rdl type SSHKeyReq struct { - Principals []string `json:"principals"` - Ips []string `json:"ips,omitempty" rdl:"optional"` - Pubkey string `json:"pubkey"` - Reqip string `json:"reqip"` - Requser string `json:"requser"` - Certtype string `json:"certtype"` - Transid string `json:"transid"` - Command string `json:"command,omitempty" rdl:"optional"` + Principals []string `json:"principals"` + XPrincipals []string `json:"xprincipals,omitempty" rdl:"optional"` + Ips []string `json:"ips,omitempty" rdl:"optional"` + Pubkey string `json:"pubkey"` + Reqip string `json:"reqip"` + Requser string `json:"requser"` + Certtype string `json:"certtype"` + Transid string `json:"transid"` + Command string `json:"command,omitempty" rdl:"optional"` } const JwkConfFile = "athenz.conf" @@ -420,6 +421,50 @@ func GenerateRoleCertCSR(key *rsa.PrivateKey, options *RoleCertReqOptions) (stri return GenerateX509CSR(key, csrDetails) } +func GenerateSSHHostCSRWithXPrincipals(sshPubKeyFile string, domain, service, hostname, ip, sshPrincipals string, ztsCloudDomains []string) (string, error) { + + log.Println("Generating SSH Host Certificate CSR...") + + pubkey, err := os.ReadFile(sshPubKeyFile) + if err != nil { + log.Printf("Skipping SSH CSR Request - Unable to read SSH Public Key File: %v\n", err) + return "", nil + } + identity := domain + "." + service + transId := fmt.Sprintf("%x", time.Now().Unix()) + hyphenDomain := strings.Replace(domain, ".", "-", -1) + principals := []string{} + xprincipals := []string{} + if hostname != "" { + xprincipals = append(xprincipals, hostname) + } + if sshPrincipals != "" { + xprincipals = append(xprincipals, strings.Split(sshPrincipals, ",")...) + } + if ip != "" { + xprincipals = append(xprincipals, ip) + } + for _, ztsDomain := range ztsCloudDomains { + host := fmt.Sprintf("%s.%s.%s", service, hyphenDomain, ztsDomain) + principals = append(principals, host) + xprincipals = append(xprincipals, host) + } + req := &SSHKeyReq{ + Principals: principals, + XPrincipals: xprincipals, + Pubkey: string(pubkey), + Reqip: ip, + Requser: identity, + Certtype: "host", + Transid: transId, + } + csr, err := json.Marshal(req) + if err != nil { + return "", err + } + return string(csr), err +} + func GenerateSSHHostCSR(sshPubKeyFile string, domain, service, ip string, ztsCloudDomains []string) (string, error) { log.Println("Generating SSH Host Certificate CSR...") diff --git a/libs/go/sia/util/util_test.go b/libs/go/sia/util/util_test.go index d0396260063..75c236dec3d 100644 --- a/libs/go/sia/util/util_test.go +++ b/libs/go/sia/util/util_test.go @@ -18,6 +18,7 @@ package util import ( "crypto/x509" + "encoding/json" "encoding/pem" "fmt" "net" @@ -1389,6 +1390,132 @@ func TestSetupSIADir(t *testing.T) { os.RemoveAll("/tmp/sia-test-dir") } +func TestGenerateSSHHostCSR(t *testing.T) { + + // using invalid key file which should return an empty csr with no error + + csr, err := GenerateSSHHostCSR("unknown-file", "athenz", "api", "10.11.12.13", []string{"athenz.cloud"}) + assert.Nil(t, err) + assert.Empty(t, csr) + + // now let's test with real ssh pub key file + + csr, err = GenerateSSHHostCSR("data/ssh-pub-key", "athenz.prod", "api", "10.11.12.13", []string{"athenz.cloud", "athenz.io"}) + assert.Nil(t, err) + + var req SSHKeyReq + err = json.Unmarshal([]byte(csr), &req) + assert.Nil(t, err) + + assert.Equal(t, "host", req.Certtype) + assert.Equal(t, "10.11.12.13", req.Reqip) + assert.Equal(t, "athenz.prod.api", req.Requser) + assert.Equal(t, "ssh-pub-key", req.Pubkey) + assert.NotEmpty(t, req.Transid) + assert.Empty(t, req.Command) + assert.Empty(t, req.Ips) + + // the legacy csr only includes the zts cloud domain based principals and + // must not carry an x-principals field at all - an empty x-principals list + // is not the same as an absent one since the server only skips hostname + // validation when the field is not present + + assert.Equal(t, []string{"api.athenz-prod.athenz.cloud", "api.athenz-prod.athenz.io"}, req.Principals) + assert.Nil(t, req.XPrincipals) + assert.NotContains(t, csr, "xprincipals") +} + +func TestGenerateSSHHostCSRWithXPrincipals(t *testing.T) { + + // using invalid key file which should return an empty csr with no error + + csr, err := GenerateSSHHostCSRWithXPrincipals("unknown-file", "athenz", "api", "hostname.athenz.io", "10.11.12.13", "host1.athenz.io,host2.athenz.io", []string{"athenz.cloud"}) + assert.Nil(t, err) + assert.Empty(t, csr) + + // now let's test with real ssh pub key file + + csr, err = GenerateSSHHostCSRWithXPrincipals("data/ssh-pub-key", "athenz", "api", "hostname.athenz.io", "10.11.12.13", "host1.athenz.io,host2.athenz.io", []string{"athenz.cloud"}) + assert.Nil(t, err) + + var req SSHKeyReq + err = json.Unmarshal([]byte(csr), &req) + assert.Nil(t, err) + + assert.Equal(t, "host", req.Certtype) + assert.Equal(t, "10.11.12.13", req.Reqip) + assert.Equal(t, "athenz.api", req.Requser) + assert.Equal(t, "ssh-pub-key", req.Pubkey) + assert.NotEmpty(t, req.Transid) + assert.Empty(t, req.Command) + assert.Empty(t, req.Ips) + + // the principals field only carries the zts cloud domain based hostnames + // which are used to generate the key id, while the x-principals field + // carries the full set of hostnames the certificate is requested for + + assert.Equal(t, []string{"api.athenz.athenz.cloud"}, req.Principals) + assert.Equal(t, []string{ + "hostname.athenz.io", + "host1.athenz.io", + "host2.athenz.io", + "10.11.12.13", + "api.athenz.athenz.cloud", + }, req.XPrincipals) + assert.Contains(t, csr, "xprincipals") + + // now let's test with multiple zts cloud domains and a domain with + // multiple components which must be hyphenated in the hostnames + + csr, err = GenerateSSHHostCSRWithXPrincipals("data/ssh-pub-key", "athenz.prod", "api", "hostname.athenz.io", "10.11.12.13", "host1.athenz.io", []string{"athenz.cloud", "athenz.io"}) + assert.Nil(t, err) + + req = SSHKeyReq{} + err = json.Unmarshal([]byte(csr), &req) + assert.Nil(t, err) + + assert.Equal(t, "athenz.prod.api", req.Requser) + assert.Equal(t, []string{"api.athenz-prod.athenz.cloud", "api.athenz-prod.athenz.io"}, req.Principals) + assert.Equal(t, []string{ + "hostname.athenz.io", + "host1.athenz.io", + "10.11.12.13", + "api.athenz-prod.athenz.cloud", + "api.athenz-prod.athenz.io", + }, req.XPrincipals) + + // now let's test without any of the optional arguments - hostname, + // ip and ssh principals + + csr, err = GenerateSSHHostCSRWithXPrincipals("data/ssh-pub-key", "athenz", "api", "", "", "", []string{"athenz.cloud"}) + assert.Nil(t, err) + + req = SSHKeyReq{} + err = json.Unmarshal([]byte(csr), &req) + assert.Nil(t, err) + + assert.Equal(t, "host", req.Certtype) + assert.Empty(t, req.Reqip) + assert.Equal(t, "athenz.api", req.Requser) + assert.Equal(t, []string{"api.athenz.athenz.cloud"}, req.Principals) + assert.Equal(t, []string{"api.athenz.athenz.cloud"}, req.XPrincipals) + + // finally with no optional arguments and no zts cloud domains there are no + // x-principals to report, and the field must be omitted rather than sent as + // an empty list which the server would validate the hostname against + + csr, err = GenerateSSHHostCSRWithXPrincipals("data/ssh-pub-key", "athenz", "api", "", "", "", nil) + assert.Nil(t, err) + + req = SSHKeyReq{} + err = json.Unmarshal([]byte(csr), &req) + assert.Nil(t, err) + + assert.Empty(t, req.Principals) + assert.Nil(t, req.XPrincipals) + assert.NotContains(t, csr, "xprincipals") +} + func TestGenerateSSHHostRequest(t *testing.T) { // using invalid key file which should return nil