Skip to content

Commit

Permalink
Reduce security problems by deleting challenge ingress when it is not… (
Browse files Browse the repository at this point in the history
#25)

Create and delete challenge ingress to reduce security risk
Automatically generate key if it's not provided
Simplify initial setup instructions by skipping ACME Key and SMTP
Ensure that only one certificate renewal executes at a time
  • Loading branch information
nabsul authored Feb 26, 2022
1 parent 232eea3 commit 81a24e6
Show file tree
Hide file tree
Showing 14 changed files with 261 additions and 194 deletions.
16 changes: 14 additions & 2 deletions Controllers/HomeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using KCert.Services;
using Microsoft.AspNetCore.Mvc;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;

namespace KCert.Controllers;
Expand All @@ -14,16 +15,18 @@ public class HomeController : Controller
private readonly KCertConfig _cfg;
private readonly EmailClient _email;
private readonly AcmeClient _acme;
private readonly CertClient _cert;

private static string TermsOfServiceUrl;

public HomeController(KCertClient kcert, K8sClient kube, KCertConfig cfg, EmailClient email, AcmeClient acme)
public HomeController(KCertClient kcert, K8sClient kube, KCertConfig cfg, EmailClient email, AcmeClient acme, CertClient cert)
{
_kcert = kcert;
_kube = kube;
_cfg = cfg;
_email = email;
_acme = acme;
_cert = cert;
}

[HttpGet("")]
Expand Down Expand Up @@ -62,7 +65,16 @@ public async Task<IActionResult> TestEmailAsync()
[HttpGet("renew/{ns}/{name}")]
public async Task<IActionResult> RenewAsync(string ns, string name)
{
await _kcert.RenewCertAsync(ns, name);
var secret = await _kube.GetSecretAsync(ns, name);
if (secret == null)
{
return NotFound();
}

var cert = _cert.GetCert(secret);
var hosts = _cert.GetHosts(cert).ToArray();

await _kcert.StartRenewalProcessAsync(ns, name, hosts, CancellationToken.None);
return RedirectToAction("Home");
}

Expand Down
10 changes: 5 additions & 5 deletions Controllers/HttpChallengeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@ public HttpChallengeController(ILogger<HttpChallengeController> log, CertClient
_cert = cert;
}

[HttpGet("{key}")]
public IActionResult GetChallengeResults(string key)
[HttpGet("{token}")]
public IActionResult GetChallengeResults(string token)
{
_log.LogInformation("Received ACME Challenge: {key}", key);
var thumb = _cert.GetThumbprint(key);
_log.LogInformation("Received ACME Challenge: {token}", token);
var thumb = _cert.GetThumbprint(token);
if (thumb == null)
{
return NotFound();
}

return Ok($"{key}.{thumb}");
return Ok($"{token}.{thumb}");
}
}
14 changes: 12 additions & 2 deletions Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,25 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Collections.Generic;

if (args.Length > 0 && args[^1] == "generate-key")
{
Console.WriteLine("Generating ACME Key");
var key = CertClient.GenerateNewKey();
Console.WriteLine(key);
return;
}

Host.CreateDefaultBuilder(args)
var fallbacks = new Dictionary<string, string>
{
{ "Acme:Key", CertClient.GenerateNewKey() }
};

var host = Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((ctx, cfg) =>
{
cfg.AddInMemoryCollection(fallbacks);
cfg.AddUserSecrets<Program>(optional: true);
cfg.AddEnvironmentVariables();
})
Expand All @@ -25,4 +33,6 @@
services.AddHostedService<RenewalService>();
services.AddHostedService<IngressMonitorService>();
})
.Build().Run();
.Build();

host.Run();
156 changes: 104 additions & 52 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,72 +2,118 @@

KCert is a simple alternative to [cert-manager](https://github.com/jetstack/cert-manager):

- Instead of 26000 lines of yaml, `KCert` deploys with less than 150 lines
- Instead of custom resources, `KCert` uses the existing standard Kubernetes objects
- The codebase is small and easy to understand
- Deploys with around 100 lines of yaml (vs. thousands of lines for [cert-manager](https://cert-manager.io/docs/installation/))
- Does not create or need any CRDs (Custom Resource Definitions) to operate
- Runs a single service in your cluster, isolated in its own namespace

## How it Works

- KCert runs as a single-replica deployment in your cluster
- An ingress is managed to route `.acme/challenge` requests to the service
- Service provides a web UI for basic information and configuration details
- Checks for certificates needing renewal every 6 hours
- Automatically renews certificates with less than 30 days of validity
- Watches for created and updated ingresses in the cluster
- Automatically creates certificates for ingresses with the `kcert.dev/kcert=managed` label

## Installing KCert

The following instructions assume that you will be using the included `deploy.yml` file as your template for install KCert.
If you are customizing your setup you will likely need to modify the following instructions accordingly.
The following instructions assume that you will be using the included `deploy.yml` file as your template to install KCert.
If you are customizing your setup you will likely need to modify the instructions accordingly.

> Note: KCert has been tested with the Ingress [Nginx NGINX Controller](https://kubernetes.github.io/ingress-nginx/).
> If you'd like to use it with a different controller and have trouble, there may be some hidden settings that need to be tweaked.
> Please [create an issue](https://github.com/nabsul/kcert/issues) and I'd be happy to help.
Getting started with KCert is very straigh-forward.
Starting with the `deploy.yml` template in this repo, find the `env:` section.
Fill in all the required values (marked with `#` comments):

```yaml
- name: ACME__DIRURL
value: # https://acme-staging-v02.api.letsencrypt.org/directory or https://acme-v02.api.letsencrypt.org/directory
- name: ACME__TERMSACCEPTED
value: # You must set this to "true" to indicate your acceptance of Let's Encrypt's terms of service (https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf)
- name: ACME__EMAIL
value: # Your email address for Let's Encrypt and email notifications
```
Below you can find more details, but setting up KCert involves the following steps:
If this is your first time using KCert you should probably start out with `https://acme-staging-v02.api.letsencrypt.org/directory`.
Experiment and make sure everything is working as expected, then switch over to `https://acme-v02.api.letsencrypt.org/directory`.
More information this topic can be found [here](https://letsencrypt.org/docs/staging-environment/).

- Create SMTP credentials for KCert to send automatic email notifications (or skip SMTP related instructions)
- Generate a ECDSA key by running `docker run -it nabsul/kcert:1.0.0 dotnet KCert.dll generate-key`
- Create the KCert namespace with `kubectl create namespace kcert`
- Create the KCert secret with `kubectl -n kcert create secret generic kcert --from-literal=acme=[...] --from-literal=smtp=[...]`
- Fill in the `deploy.yml` file and run `kubectl apply -f deploy.yml`
- Start `kubectl -n kcert port-forward svc/kcert 80` and view the dashboard at `http://localhost`
Once you've configured your settings, deploy KCert by running `kubectl apply -f ./deploy.yml`.
Congratulations, KCert should now be running!

### Create KCert secrets
To check that everything is running as expected:

It's bad practice to save secrets in yaml files, so we will be creating them separately.
KCert configuration involves two secrets:
- Run `kubectl -n kcert logs svc/kcert` and make sure there are no error messages
- Run `kubectl -n kcert port-forward svc/kcert 80` and go to `http://localhost:80` in your browser

- The ACME ECDSA key which is needed to create and renew certificates
- The optional SMTP password if you want to have email notifications
### Recommended: Email Notifications

You can create the ECDSA key using KCert from the command line:
KCert can auotmatically send you an email notification when it renews a certificate or fails to do so.
To configure email, you'll need to provide the following SMTP configuration details:

- If you have .NET Core installed you can check out this repo and run `dotnet run generate-key`
- If you have Docker (or Podman) you can run `docker run -it nabsul/kcert:v1.0.0 dotnet KCert.dll generate-key`
- The email address, username and password of the SMTP account
- The hostname and port of the SMTP server (SSL required)

You can then create your Kubernetes secret with the following (replace the placeholders with your own values):
The password should be placed in a Kubernetes secret as follows:

```sh
kubectl create namespace kcert
kubectl -n kcert create secret generic kcert --from-literal=acme=[YOUR ACME KEY] --from-literal=smtp=[YOUR SMTP PASSWORD]
kubectl -n kcert create secret generic kcert-smtp --from-literal=password=[...]
```

### Deploy KCert
You can then add the following to the `env:` section of your deployment:

Starting with the `deploy.yml` template in this repo, find the `env:` section.
Fill in all the required values (marked with `#` comments).
If you don't want to set up email notifications you can delete all environment variables that start with `SMTP__`.
```yaml
- name: SMTP__EMAILFROM
value: [...]
- name: SMTP__HOST
value: [...]
- name: SMTP__PORT
value: "[...]" # Be sure to put the port number between quotes
- name: SMTP__USER
value: [...]
- name: SMTP__PASS
valueFrom:
secretKeyRef:
name: kcert-smtp
key: password
```

Once you've configured your settings, deploy KCert by running `kubectl apply -f ./deploy.yml`.
Congratulations, KCert should now be running!
To test your email configuration you can connect to the KCert dasboard by running
`kubectl -n kcert port-forward svc/kcert 80` and opening `http://localhost` in your browser.
From there, navigate to the configuration section.
Check that your settings are listed there, and then click "Send Test Email" to receive a test email.

To check that everything is running as expected:
### Optional: Configure a fixed ACME Key

- Run `kubectl -n kcert logs svc/kcert` and make sure there are no error messages
- Run `kubectl -n kcert port-forward svc/kcert 80` and go to `http://localhost:80` in your browser
By default KCert will generate a random secret key at startup.
For many use cases this should be fine.
If you would like to use a fixed key, you can provide it with an environment variable.

## Uninstalling KCert
You can generate your own random key with the following:

KCert does not create many resources,
and most of them are restricted to the kcert namespace.
Removing KCert from your cluster is as simple as executing these three commands:
```sh
docker run -it nabsul/kcert:v1.0.0 dotnet KCert.dll generate-key
```

Next you would need to put that generated key into a Kubernetes secret:

```sh
kubectl delete namespace kcert
kubectl delete clusterrolebinding kcert
kubectl delete clusterrole kcert
kubectl -n kcert create secret generic kcert-key --from-literal=key=[...]
```

Note that certificates created by KCert in other namespaces will NOT be deleted.
You can keep those certificates or manually delete them.
Finally, add this to your deployment's environment variables:

```yaml
- name: ACME__KEY
valueFrom:
secretKeyRef:
name: kcert-key
key: key
```

## Creating Certificates

Expand Down Expand Up @@ -176,22 +222,28 @@ with a `ACME__RENEWALCHECKTIMEHOURS` environment variable.
Note that there are two underscore (`_`) characters in between the two parts of the setting name.
For more information see the [official .NET Core documentation](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/?view=aspnetcore-6.0).

## How it Works

- An ingress definition routes `.acme/challenge` requests to KCert for HTTP challenge requests
- Service provides a web UI and to manually manage and certs
- KCert will automatically check for certificates needing renewal every 6 hours
- KCert will renew a certificate if it expires in less than 30 days
- KCert watches for created and updated ingresses in the cluster
- KCert will automatically create and manage certificates for ingresses with the `kcert.dev/kcert=managed` label

## Building from Scratch

To build your own container image: `docker build -t [your tag] .`

## Running Locally

For local development, I recommend using `dotnet user-secrets` to configure all of KCert's required settings.
You can run KCert locally with `dotnet run`.
If you have Kubectl configured to connect to your cluster,
KCert will use those settings to do the same.
KCert will use your local kubectl configuration to connect to a Kubernetes cluster.
It will behave as if it is running in the cluster and you will be able to explore any settings that might be there.

## Uninstalling KCert

KCert does not create many resources,
and most of them are restricted to the kcert namespace.
Removing KCert from your cluster is as simple as executing these three commands:

```sh
kubectl delete namespace kcert
kubectl delete clusterrolebinding kcert
kubectl delete clusterrole kcert
```

Note that certificates created by KCert in other namespaces will NOT be deleted.
You can keep those certificates or manually delete them.
25 changes: 6 additions & 19 deletions Services/CertClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public class CertClient
private const string SanOid = "2.5.29.17";

private readonly RSA _rsa = RSA.Create(2048);
private readonly Dictionary<string, DateTime> _validKeys = new();
private readonly HashSet<string> _validKeys = new();
private readonly KCertConfig _cfg;
private readonly ILogger<CertClient> _log;

Expand Down Expand Up @@ -64,28 +64,15 @@ public static string GenerateNewKey()
var key = sign.ExportECPrivateKey();
return Base64UrlTextEncoder.Encode(key);
}
public void AddChallengeKey(string key)
{
if (_cfg.AcceptAllChallenges)
{
return;
}
public void AddChallengeToken(string token) => _validKeys.Add(token);

var threshold = TimeSpan.FromHours(1);
var now = DateTime.UtcNow;
_validKeys.Add(key, now);
foreach (var p in _validKeys.Where(p => now - p.Value > threshold))
{
_log.LogInformation("Removing old key: {k}", p.Key);
_validKeys.Remove(p.Key);
}
}
public void ClearChallengeTokens() => _validKeys.Clear();

public string GetThumbprint(string key)
public string GetThumbprint(string token)
{
if (!_cfg.AcceptAllChallenges && !_validKeys.ContainsKey(key))
if (!_cfg.AcceptAllChallenges && !_validKeys.Contains(token))
{
_log.LogWarning("Rejected thumb request for {k}", key);
_log.LogWarning("Rejected thumb request for {k}", token);
return null;
}

Expand Down
Loading

0 comments on commit 81a24e6

Please sign in to comment.