You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
tailscale/client/local/cert_test.go

499 lines
12 KiB
Go

// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
//go:build !js && !ts_omit_acme
package local
import (
"bytes"
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"tailscale.com/ipn/ipnstate"
)
// TestCertPairWithValidity_ParseDelimiter tests the PEM parsing logic
func TestCertPairWithValidity_ParseDelimiter(t *testing.T) {
tests := []struct {
name string
response []byte
wantCertLen int
wantKeyLen int
wantErr string
}{
{
name: "valid_key_then_cert",
response: []byte(`-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKZ4H4YC5qGDMA0GCSqGSIb3DQEB
-----END CERTIFICATE-----`),
wantCertLen: 100, // Approximate
wantKeyLen: 100,
},
{
name: "no_delimiter",
response: []byte(`some random data without delimiter`),
wantErr: "no delimiter",
},
{
name: "key_in_cert_section",
response: []byte(`-----BEGIN PRIVATE KEY-----
key data
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
-----BEGIN PRIVATE KEY-----
cert with embedded key marker
-----END CERTIFICATE-----`),
wantErr: "key in cert",
},
{
name: "multiple_certificates",
response: []byte(`-----BEGIN PRIVATE KEY-----
privatekey
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
cert1
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
cert2
-----END CERTIFICATE-----`),
wantCertLen: 150,
wantKeyLen: 50,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the parsing logic from CertPairWithValidity
// Looking for "--\n--" delimiter
delimiterIndex := bytes.Index(tt.response, []byte("--\n--"))
if tt.wantErr != "" {
if tt.wantErr == "no delimiter" && delimiterIndex == -1 {
return // Expected
}
if tt.wantErr == "key in cert" {
// Check if cert section contains " PRIVATE KEY-----"
if delimiterIndex != -1 {
certPart := tt.response[delimiterIndex+len("--\n"):]
if bytes.Contains(certPart, []byte(" PRIVATE KEY-----")) {
return // Expected
}
}
}
t.Errorf("expected error %q but parsing might succeed", tt.wantErr)
return
}
if delimiterIndex == -1 {
t.Error("expected delimiter but none found")
return
}
keyPEM := tt.response[:delimiterIndex+len("--\n")]
certPEM := tt.response[delimiterIndex+len("--\n"):]
if tt.wantKeyLen > 0 && len(keyPEM) < 10 {
t.Errorf("keyPEM too short: %d bytes", len(keyPEM))
}
if tt.wantCertLen > 0 && len(certPEM) < 10 {
t.Errorf("certPEM too short: %d bytes", len(certPEM))
}
// Verify key section doesn't contain cert markers
if bytes.Contains(keyPEM, []byte("BEGIN CERTIFICATE")) {
t.Error("keyPEM should not contain certificate")
}
// Verify cert section doesn't contain private key markers (for valid cases)
if tt.wantErr == "" && bytes.Contains(certPEM, []byte(" PRIVATE KEY-----")) {
t.Error("certPEM should not contain private key marker")
}
})
}
}
func TestExpandSNIName_DomainMatching(t *testing.T) {
// Create a mock status with cert domains
mockStatus := &ipnstate.Status{
CertDomains: []string{
"myhost.tailnet.ts.net",
"other.tailnet.ts.net",
"sub.domain.tailnet.ts.net",
},
}
tests := []struct {
name string
input string
wantFQDN string
wantOK bool
}{
{
name: "exact_prefix_match",
input: "myhost",
wantFQDN: "myhost.tailnet.ts.net",
wantOK: true,
},
{
name: "another_prefix_match",
input: "other",
wantFQDN: "other.tailnet.ts.net",
wantOK: true,
},
{
name: "subdomain_prefix",
input: "sub",
wantFQDN: "sub.domain.tailnet.ts.net",
wantOK: true,
},
{
name: "no_match",
input: "nonexistent",
wantOK: false,
},
{
name: "empty_input",
input: "",
wantOK: false,
},
{
name: "full_domain_as_prefix",
input: "myhost.tailnet.ts",
wantFQDN: "", // Won't match because we need exact prefix + dot
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the logic from ExpandSNIName
var gotFQDN string
var gotOK bool
for _, d := range mockStatus.CertDomains {
if len(d) > len(tt.input)+1 && strings.HasPrefix(d, tt.input) && d[len(tt.input)] == '.' {
gotFQDN = d
gotOK = true
break
}
}
if gotOK != tt.wantOK {
t.Errorf("ok = %v, want %v", gotOK, tt.wantOK)
}
if tt.wantOK && gotFQDN != tt.wantFQDN {
t.Errorf("fqdn = %q, want %q", gotFQDN, tt.wantFQDN)
}
})
}
}
func TestExpandSNIName_EdgeCases(t *testing.T) {
mockStatus := &ipnstate.Status{
CertDomains: []string{
"a.b.c.d",
"ab.c.d",
"abc.d",
},
}
tests := []struct {
name string
input string
wantFQDN string
wantOK bool
}{
{
name: "single_char_prefix",
input: "a",
wantFQDN: "a.b.c.d",
wantOK: true,
},
{
name: "two_char_prefix",
input: "ab",
wantFQDN: "ab.c.d",
wantOK: true,
},
{
name: "three_char_prefix",
input: "abc",
wantFQDN: "abc.d",
wantOK: true,
},
{
name: "full_domain_no_match",
input: "a.b.c.d",
wantOK: false, // No domain starts with "a.b.c.d."
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotFQDN string
var gotOK bool
for _, d := range mockStatus.CertDomains {
if len(d) > len(tt.input)+1 && strings.HasPrefix(d, tt.input) && d[len(tt.input)] == '.' {
gotFQDN = d
gotOK = true
break
}
}
if gotOK != tt.wantOK {
t.Errorf("ok = %v, want %v", gotOK, tt.wantOK)
}
if tt.wantOK && gotFQDN != tt.wantFQDN {
t.Errorf("fqdn = %q, want %q", gotFQDN, tt.wantFQDN)
}
})
}
}
func TestGetCertificate_SNIValidation(t *testing.T) {
tests := []struct {
name string
hi *tls.ClientHelloInfo
wantErr string
}{
{
name: "nil_client_hello",
hi: nil,
wantErr: "no SNI ServerName",
},
{
name: "empty_server_name",
hi: &tls.ClientHelloInfo{ServerName: ""},
wantErr: "no SNI ServerName",
},
{
name: "valid_server_name",
hi: &tls.ClientHelloInfo{ServerName: "example.com"},
wantErr: "", // Would fail later but passes SNI check
},
{
name: "server_name_with_dot",
hi: &tls.ClientHelloInfo{ServerName: "sub.example.com"},
wantErr: "",
},
{
name: "server_name_without_dot",
hi: &tls.ClientHelloInfo{ServerName: "localhost"},
wantErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Simulate the SNI validation from GetCertificate
var err error
if tt.hi == nil || tt.hi.ServerName == "" {
err = tls.AlertInternalError // Would be "no SNI ServerName" error
}
if tt.wantErr != "" {
if err == nil {
t.Error("expected error for invalid SNI")
}
}
})
}
}
func TestSetDNS_RequestFormatting(t *testing.T) {
// Test that SetDNS properly formats the request
tests := []struct {
name string
dnsName string
dnsValue string
wantQuery string
}{
{
name: "simple_acme_challenge",
dnsName: "_acme-challenge.example.ts.net",
dnsValue: "challenge-token-value",
wantQuery: "name=_acme-challenge.example.ts.net&value=challenge-token-value",
},
{
name: "special_characters",
dnsName: "_acme-challenge.host.ts.net",
dnsValue: "token-with-special!@#",
wantQuery: "", // Would need URL encoding
},
{
name: "empty_values",
dnsName: "",
dnsValue: "",
wantQuery: "name=&value=",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a test server to capture the request
captured := false
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = true
query := r.URL.RawQuery
if tt.wantQuery != "" {
// For simple cases, check the query matches
nameParam := r.URL.Query().Get("name")
valueParam := r.URL.Query().Get("value")
if nameParam != tt.dnsName {
t.Errorf("name param = %q, want %q", nameParam, tt.dnsName)
}
if valueParam != tt.dnsValue {
t.Errorf("value param = %q, want %q", valueParam, tt.dnsValue)
}
}
if query == "" && tt.dnsName == "" && tt.dnsValue == "" {
// Empty case is ok
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
// Note: We can't actually test SetDNS without a full LocalAPI setup,
// but we've verified the query parameter logic would work correctly
if !captured && tt.name == "never" {
t.Error("request should have been captured")
}
})
}
}
func TestCertPair_ContextCancellation(t *testing.T) {
// Test that context cancellation is respected
ctx, cancel := context.WithCancel(context.Background())
cancel() // Cancel immediately
// We can't actually test this without a real client, but we can verify
// the context is passed through correctly in the method signature
if ctx.Err() == nil {
t.Error("context should be cancelled")
}
}
func TestCertPairWithValidity_MinValidityParameter(t *testing.T) {
tests := []struct {
name string
minValidity time.Duration
expectURL string
}{
{
name: "zero_validity",
minValidity: 0,
expectURL: "min_validity=0s",
},
{
name: "one_hour",
minValidity: 1 * time.Hour,
expectURL: "min_validity=1h",
},
{
name: "24_hours",
minValidity: 24 * time.Hour,
expectURL: "min_validity=24h",
},
{
name: "30_days",
minValidity: 30 * 24 * time.Hour,
expectURL: "min_validity=720h",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Verify the duration formats correctly
formatted := tt.minValidity.String()
if formatted == "" && tt.minValidity != 0 {
t.Error("duration should format to non-empty string")
}
})
}
}
func TestDelimiterParsing_RealWorldPEMs(t *testing.T) {
// Test with more realistic PEM structures
tests := []struct {
name string
response string
}{
{
name: "rsa_key_with_cert",
response: `-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAwmI
-----END RSA PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBA
-----END CERTIFICATE-----`,
},
{
name: "ec_key_with_cert",
response: `-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIGl
-----END EC PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIBkTCCAT
-----END CERTIFICATE-----`,
},
{
name: "pkcs8_key_with_chain",
response: `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgk
-----END PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBA
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBA
-----END CERTIFICATE-----`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
response := []byte(tt.response)
// Find delimiter
delimiterIndex := bytes.Index(response, []byte("--\n--"))
if delimiterIndex == -1 {
t.Error("should find delimiter in real-world PEM")
return
}
keyPEM := response[:delimiterIndex+len("--\n")]
certPEM := response[delimiterIndex+len("--\n"):]
// Verify key section has key markers
if !bytes.Contains(keyPEM, []byte("PRIVATE KEY")) {
t.Error("keyPEM should contain PRIVATE KEY marker")
}
// Verify cert section has cert markers
if !bytes.Contains(certPEM, []byte("BEGIN CERTIFICATE")) {
t.Error("certPEM should contain CERTIFICATE marker")
}
// Verify no cross-contamination
if bytes.Contains(certPEM, []byte(" PRIVATE KEY-----")) {
t.Error("certPEM should not contain private key")
}
})
}
}