|
| 1 | +package certs_vault |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/base64" |
| 6 | + "fmt" |
| 7 | + "github.com/hashicorp/vault-client-go" |
| 8 | + "github.com/hashicorp/vault-client-go/schema" |
| 9 | + hlfv1alpha1 "github.com/kfsoftware/hlf-operator/pkg/apis/hlf.kungfusoftware.es/v1alpha1" |
| 10 | + "github.com/stretchr/testify/assert" |
| 11 | + "github.com/stretchr/testify/require" |
| 12 | + "github.com/testcontainers/testcontainers-go" |
| 13 | + "github.com/testcontainers/testcontainers-go/wait" |
| 14 | + corev1 "k8s.io/api/core/v1" |
| 15 | + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 16 | + "k8s.io/client-go/kubernetes" |
| 17 | + "k8s.io/client-go/kubernetes/fake" |
| 18 | + "testing" |
| 19 | + "time" |
| 20 | +) |
| 21 | + |
| 22 | +const ( |
| 23 | + vaultImage = "hashicorp/vault:1.7.2" |
| 24 | + vaultPort = "8200" |
| 25 | + vaultRootToken = "test-root-token" |
| 26 | + vaultTokenSecret = "vault-token" |
| 27 | + vaultNamespace = "default" |
| 28 | + pkiMountPath = "test" |
| 29 | + caIssuerName = "test-ca" |
| 30 | + caCommonName = "Test CA" |
| 31 | + caTTL = "87600h" |
| 32 | + roleName = "fabric" |
| 33 | + roleMaxTTL = "87600h" |
| 34 | + roleKeyType = "ec" |
| 35 | + roleKeyBits = 256 |
| 36 | + roleOU = "peer" |
| 37 | + roleOrg = "Org1MSP" |
| 38 | + certTTL = "24h" |
| 39 | + certUser = "testUser" |
| 40 | + certUserCN = "testUserCN" |
| 41 | + certHost = "localhost" |
| 42 | + certMSPID = "Org1MSP" |
| 43 | + startupTimeout = 60 * time.Second |
| 44 | + internalSleep = 2 * time.Second |
| 45 | + requestTimeout = 30 * time.Second |
| 46 | + certExpiryMargin = time.Hour |
| 47 | +) |
| 48 | + |
| 49 | +type VaultContainer struct { |
| 50 | + testcontainers.Container |
| 51 | + Address string |
| 52 | + RootToken string |
| 53 | +} |
| 54 | + |
| 55 | +func setupVaultDev(ctx context.Context) (*VaultContainer, error) { |
| 56 | + req := testcontainers.ContainerRequest{ |
| 57 | + Image: vaultImage, |
| 58 | + ExposedPorts: []string{vaultPort + "/tcp"}, |
| 59 | + Env: map[string]string{ |
| 60 | + "VAULT_DEV_ROOT_TOKEN_ID": vaultRootToken, |
| 61 | + }, |
| 62 | + Cmd: []string{ |
| 63 | + "server", |
| 64 | + "-dev", |
| 65 | + "-dev-root-token-id=" + vaultRootToken, |
| 66 | + "-dev-listen-address=0.0.0.0:" + vaultPort, |
| 67 | + }, |
| 68 | + WaitingFor: wait.ForHTTP("/v1/sys/health"). |
| 69 | + WithPort(vaultPort + "/tcp"). |
| 70 | + WithStartupTimeout(startupTimeout). |
| 71 | + WithStatusCodeMatcher(func(code int) bool { |
| 72 | + return code == 200 || code == 429 |
| 73 | + }), |
| 74 | + } |
| 75 | + |
| 76 | + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ |
| 77 | + ContainerRequest: req, |
| 78 | + Started: true, |
| 79 | + }) |
| 80 | + if err != nil { |
| 81 | + return nil, fmt.Errorf("starting vault container: %w", err) |
| 82 | + } |
| 83 | + |
| 84 | + mappedPort, err := container.MappedPort(ctx, vaultPort) |
| 85 | + if err != nil { |
| 86 | + return nil, fmt.Errorf("getting mapped port: %w", err) |
| 87 | + } |
| 88 | + |
| 89 | + host, err := container.Host(ctx) |
| 90 | + if err != nil { |
| 91 | + return nil, fmt.Errorf("getting container host: %w", err) |
| 92 | + } |
| 93 | + |
| 94 | + address := fmt.Sprintf("http://%s:%s", host, mappedPort.Port()) |
| 95 | + time.Sleep(internalSleep) |
| 96 | + |
| 97 | + return &VaultContainer{ |
| 98 | + Container: container, |
| 99 | + Address: address, |
| 100 | + RootToken: vaultRootToken, |
| 101 | + }, nil |
| 102 | +} |
| 103 | + |
| 104 | +func TestEnrollUser(t *testing.T) { |
| 105 | + |
| 106 | + ctx := context.Background() |
| 107 | + |
| 108 | + vaultContainer, err := setupVaultDev(ctx) |
| 109 | + require.NoError(t, err, "Failed to setup vault") |
| 110 | + defer func() { |
| 111 | + assert.NoError(t, vaultContainer.Terminate(ctx), "Failed to terminate container") |
| 112 | + }() |
| 113 | + |
| 114 | + vaultClient, err := vault.New( |
| 115 | + vault.WithAddress(vaultContainer.Address), |
| 116 | + vault.WithRequestTimeout(requestTimeout), |
| 117 | + ) |
| 118 | + require.NoError(t, err, "Failed to create Vault client") |
| 119 | + err = vaultClient.SetToken(vaultContainer.RootToken) |
| 120 | + require.NoError(t, err, "Failed to set Vault token") |
| 121 | + |
| 122 | + err = EnablePKI(ctx, vaultClient, pkiMountPath, caTTL) |
| 123 | + require.NoError(t, err, "Failed to enable PKI") |
| 124 | + |
| 125 | + err = CreateVaultIssuer(ctx, vaultClient, pkiMountPath, caIssuerName, map[string]interface{}{ |
| 126 | + "key_type": roleKeyType, |
| 127 | + "key_bits": roleKeyBits, |
| 128 | + "ttl": caTTL, |
| 129 | + "common_name": caCommonName, |
| 130 | + }) |
| 131 | + require.NoError(t, err, "Failed to create Vault issuer") |
| 132 | + |
| 133 | + err = CreateVaultRole(ctx, vaultClient, roleName, map[string]interface{}{ |
| 134 | + "issuer_ref": caIssuerName, |
| 135 | + "allow_subdomains": true, |
| 136 | + "allow_any_name": true, |
| 137 | + "max_ttl": roleMaxTTL, |
| 138 | + "key_type": roleKeyType, |
| 139 | + "key_bits": roleKeyBits, |
| 140 | + "ou": roleOU, |
| 141 | + "organization": roleOrg, |
| 142 | + }) |
| 143 | + require.NoError(t, err, "Failed to create Vault role") |
| 144 | + |
| 145 | + clientSet := GetFakeClientsetWithVaultToken() |
| 146 | + |
| 147 | + vaultConf := &hlfv1alpha1.VaultSpecConf{ |
| 148 | + URL: vaultContainer.Address, |
| 149 | + TLSSkipVerify: true, |
| 150 | + TokenSecretRef: &hlfv1alpha1.VaultSecretRef{ |
| 151 | + Name: vaultTokenSecret, |
| 152 | + Namespace: vaultNamespace, |
| 153 | + Key: "token", |
| 154 | + }, |
| 155 | + } |
| 156 | + request := &hlfv1alpha1.VaultPKICertificateRequest{ |
| 157 | + PKI: pkiMountPath, |
| 158 | + Role: roleName, |
| 159 | + TTL: certTTL, |
| 160 | + } |
| 161 | + params := EnrollUserRequest{ |
| 162 | + MSPID: certMSPID, |
| 163 | + User: certUser, |
| 164 | + Hosts: []string{certHost}, |
| 165 | + CN: certUserCN, |
| 166 | + } |
| 167 | + |
| 168 | + cert, privateKey, caCert, err := EnrollUser(clientSet, vaultConf, request, params) |
| 169 | + require.NoError(t, err, "Failed to enroll user") |
| 170 | + assert.NotNil(t, cert, "Certificate should not be nil") |
| 171 | + assert.NotNil(t, privateKey, "Private key should not be nil") |
| 172 | + assert.NotNil(t, caCert, "CA certificate should not be nil") |
| 173 | + expiry := cert.NotAfter |
| 174 | + expectedExpiry := time.Now().Add(24 * time.Hour) |
| 175 | + assert.WithinDuration(t, expectedExpiry, expiry, certExpiryMargin, "Certificate expiry does not match expected TTL") |
| 176 | +} |
| 177 | + |
| 178 | +func TestReenrollUser(t *testing.T) { |
| 179 | + ctx := context.Background() |
| 180 | + vaultContainer, err := setupVaultDev(ctx) |
| 181 | + require.NoError(t, err, "Failed to setup vault") |
| 182 | + defer func() { |
| 183 | + assert.NoError(t, vaultContainer.Terminate(ctx), "Failed to terminate container") |
| 184 | + }() |
| 185 | + |
| 186 | + vaultClient, err := vault.New( |
| 187 | + vault.WithAddress(vaultContainer.Address), |
| 188 | + vault.WithRequestTimeout(requestTimeout), |
| 189 | + ) |
| 190 | + require.NoError(t, err, "Failed to create Vault client") |
| 191 | + err = vaultClient.SetToken(vaultContainer.RootToken) |
| 192 | + require.NoError(t, err, "Failed to set Vault token") |
| 193 | + |
| 194 | + err = EnablePKI(ctx, vaultClient, pkiMountPath, caTTL) |
| 195 | + require.NoError(t, err, "Failed to enable PKI") |
| 196 | + |
| 197 | + err = CreateVaultIssuer(ctx, vaultClient, pkiMountPath, caIssuerName, map[string]interface{}{ |
| 198 | + "key_type": roleKeyType, |
| 199 | + "key_bits": roleKeyBits, |
| 200 | + "ttl": caTTL, |
| 201 | + "common_name": caCommonName, |
| 202 | + }) |
| 203 | + require.NoError(t, err, "Failed to create Vault issuer") |
| 204 | + |
| 205 | + err = CreateVaultRole(ctx, vaultClient, roleName, map[string]interface{}{ |
| 206 | + "issuer_ref": caIssuerName, |
| 207 | + "allow_subdomains": true, |
| 208 | + "allow_any_name": true, |
| 209 | + "max_ttl": roleMaxTTL, |
| 210 | + "key_type": roleKeyType, |
| 211 | + "key_bits": roleKeyBits, |
| 212 | + "ou": roleOU, |
| 213 | + "organization": roleOrg, |
| 214 | + }) |
| 215 | + require.NoError(t, err, "Failed to create Vault role") |
| 216 | + |
| 217 | + clientSet := GetFakeClientsetWithVaultToken() |
| 218 | + vaultConf := &hlfv1alpha1.VaultSpecConf{ |
| 219 | + URL: vaultContainer.Address, |
| 220 | + TLSSkipVerify: true, |
| 221 | + TokenSecretRef: &hlfv1alpha1.VaultSecretRef{ |
| 222 | + Name: vaultTokenSecret, |
| 223 | + Namespace: vaultNamespace, |
| 224 | + Key: "token", |
| 225 | + }, |
| 226 | + } |
| 227 | + request := &hlfv1alpha1.VaultPKICertificateRequest{ |
| 228 | + PKI: pkiMountPath, |
| 229 | + Role: roleName, |
| 230 | + TTL: certTTL, |
| 231 | + } |
| 232 | + enrollUserRequest := EnrollUserRequest{ |
| 233 | + MSPID: certMSPID, |
| 234 | + User: certUser, |
| 235 | + Hosts: []string{certHost}, |
| 236 | + CN: certUserCN, |
| 237 | + } |
| 238 | + |
| 239 | + cert1, privateKey1, caCert1, err := EnrollUser(clientSet, vaultConf, request, enrollUserRequest) |
| 240 | + require.NoError(t, err, "Failed to enroll user") |
| 241 | + require.NotNil(t, cert1, "Certificate should not be nil") |
| 242 | + require.NotNil(t, privateKey1, "Private key should not be nil") |
| 243 | + require.NotNil(t, caCert1, "CA certificate should not be nil") |
| 244 | + |
| 245 | + certPEM1 := fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n", |
| 246 | + base64.StdEncoding.EncodeToString(cert1.Raw)) |
| 247 | + |
| 248 | + reenrollUserRequest := ReenrollUserRequest{ |
| 249 | + MSPID: certMSPID, |
| 250 | + EnrollID: certUserCN, |
| 251 | + Hosts: []string{certHost}, |
| 252 | + CN: certUserCN, |
| 253 | + } |
| 254 | + |
| 255 | + cert2, caCert2, err := ReenrollUser(clientSet, vaultConf, request, reenrollUserRequest, certPEM1, privateKey1) |
| 256 | + require.NoError(t, err, "Failed to reenroll user") |
| 257 | + require.NotNil(t, cert2, "Reenrolled certificate should not be nil") |
| 258 | + require.NotNil(t, caCert2, "Reenrolled CA certificate should not be nil") |
| 259 | + |
| 260 | + assert.Equal(t, cert1.PublicKey, cert2.PublicKey, "Private keys should be the same after reenrollment") |
| 261 | +} |
| 262 | + |
| 263 | +func GetFakeClientsetWithVaultToken() kubernetes.Interface { |
| 264 | + secret := &corev1.Secret{ |
| 265 | + ObjectMeta: metav1.ObjectMeta{ |
| 266 | + Name: vaultTokenSecret, |
| 267 | + Namespace: vaultNamespace, |
| 268 | + }, |
| 269 | + Type: corev1.SecretTypeOpaque, |
| 270 | + Data: map[string][]byte{ |
| 271 | + "token": []byte(vaultRootToken), |
| 272 | + }, |
| 273 | + } |
| 274 | + return fake.NewClientset(secret) |
| 275 | +} |
| 276 | + |
| 277 | +// CreateVaultRole creates a new role in Vault PKI with the given parameters |
| 278 | +func CreateVaultRole(ctx context.Context, vaultClient *vault.Client, roleName string, params map[string]interface{}) error { |
| 279 | + if roleName == "" { |
| 280 | + return fmt.Errorf("roleName cannot be empty") |
| 281 | + } |
| 282 | + if params == nil { |
| 283 | + return fmt.Errorf("params cannot be nil") |
| 284 | + } |
| 285 | + |
| 286 | + rolePath := fmt.Sprintf("%s/roles/%s", pkiMountPath, roleName) |
| 287 | + _, err := vaultClient.Write(ctx, rolePath, params) |
| 288 | + if err != nil { |
| 289 | + return fmt.Errorf("failed to create role in Vault: %w", err) |
| 290 | + } |
| 291 | + |
| 292 | + return nil |
| 293 | +} |
| 294 | + |
| 295 | +// EnablePKI enables the PKI secrets engine at the given mount path |
| 296 | +func EnablePKI(ctx context.Context, vaultClient *vault.Client, mountPath string, maxLeaseTTL string) error { |
| 297 | + if mountPath == "" { |
| 298 | + return fmt.Errorf("mountPath cannot be empty") |
| 299 | + } |
| 300 | + if maxLeaseTTL == "" { |
| 301 | + maxLeaseTTL = "87600h" |
| 302 | + } |
| 303 | + |
| 304 | + req := schema.MountsEnableSecretsEngineRequest{ |
| 305 | + Type: "pki", |
| 306 | + Config: map[string]interface{}{ |
| 307 | + "max_lease_ttl": maxLeaseTTL, |
| 308 | + }, |
| 309 | + } |
| 310 | + |
| 311 | + _, err := vaultClient.System.MountsEnableSecretsEngine(ctx, mountPath, req) |
| 312 | + if err != nil { |
| 313 | + return fmt.Errorf("failed to enable PKI engine: %w", err) |
| 314 | + } |
| 315 | + |
| 316 | + return nil |
| 317 | +} |
| 318 | + |
| 319 | +// CreateVaultIssuer creates a new issuer (CA) in Vault PKI |
| 320 | +func CreateVaultIssuer(ctx context.Context, vaultClient *vault.Client, pki, issuerName string, params map[string]interface{}) error { |
| 321 | + if pki == "" { |
| 322 | + return fmt.Errorf("pki mount path cannot be empty") |
| 323 | + } |
| 324 | + if issuerName == "" { |
| 325 | + return fmt.Errorf("issuerName cannot be empty") |
| 326 | + } |
| 327 | + if params == nil { |
| 328 | + return fmt.Errorf("params cannot be nil") |
| 329 | + } |
| 330 | + |
| 331 | + issuerPath := fmt.Sprintf("%s/root/generate/internal", pki) |
| 332 | + params["issuer_name"] = issuerName |
| 333 | + _, err := vaultClient.Write(ctx, issuerPath, params) |
| 334 | + if err != nil { |
| 335 | + return fmt.Errorf("failed to create issuer in Vault: %w", err) |
| 336 | + } |
| 337 | + |
| 338 | + return nil |
| 339 | +} |
0 commit comments