Update engine specs, audit doc, and server tests for SSH CA, transit, and user engines
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
425
engines/sshca.md
425
engines/sshca.md
@@ -17,11 +17,13 @@ Passed as `config` at mount time:
|
||||
|
||||
| Field | Default | Description |
|
||||
|-----------------|------------------|------------------------------------------|
|
||||
| `key_algorithm` | `"ed25519"` | CA key type: ed25519, ecdsa, rsa |
|
||||
| `key_size` | `0` | Key size (ignored for ed25519; 256/384/521 for ECDSA, 2048/4096 for RSA) |
|
||||
| `key_algorithm` | `"ed25519"` | CA key type: `ed25519`, `ecdsa-p256`, `ecdsa-p384` |
|
||||
| `max_ttl` | `"87600h"` | Maximum certificate validity |
|
||||
| `default_ttl` | `"24h"` | Default certificate validity |
|
||||
|
||||
RSA is intentionally excluded — Ed25519 and ECDSA are preferred for SSH CAs.
|
||||
This avoids the need for a `key_size` parameter and simplifies key generation.
|
||||
|
||||
## Barrier Storage Layout
|
||||
|
||||
```
|
||||
@@ -30,19 +32,20 @@ engine/sshca/{mount}/ca/key.pem CA private key (PEM, PKCS8)
|
||||
engine/sshca/{mount}/ca/pubkey.pub CA public key (SSH authorized_keys format)
|
||||
engine/sshca/{mount}/profiles/{name}.json Signing profiles
|
||||
engine/sshca/{mount}/certs/{serial}.json Signed cert records
|
||||
engine/sshca/{mount}/krl.bin Current KRL (OpenSSH format)
|
||||
engine/sshca/{mount}/krl_version.json KRL version counter
|
||||
```
|
||||
|
||||
## In-Memory State
|
||||
|
||||
```go
|
||||
type SSHCAEngine struct {
|
||||
barrier barrier.Barrier
|
||||
config *SSHCAConfig
|
||||
caKey crypto.PrivateKey // CA signing key
|
||||
caSigner ssh.Signer // ssh.Signer wrapping caKey
|
||||
mountPath string
|
||||
mu sync.RWMutex
|
||||
barrier barrier.Barrier
|
||||
config *SSHCAConfig
|
||||
caKey crypto.PrivateKey // CA signing key
|
||||
caSigner ssh.Signer // ssh.Signer wrapping caKey
|
||||
mountPath string
|
||||
krlVersion uint64 // monotonically increasing
|
||||
mu sync.RWMutex
|
||||
}
|
||||
```
|
||||
|
||||
@@ -52,20 +55,34 @@ Key material (`caKey`, `caSigner`) is zeroized on `Seal()`.
|
||||
|
||||
### Initialize
|
||||
|
||||
1. Parse and store config in barrier as `config.json`.
|
||||
2. Generate CA key pair using the configured algorithm.
|
||||
3. Store private key PEM and SSH public key in barrier.
|
||||
4. Load key into memory as `ssh.Signer`.
|
||||
1. Parse and validate config: ensure `key_algorithm` is one of `ed25519`,
|
||||
`ecdsa-p256`, `ecdsa-p384`. Parse `max_ttl` and `default_ttl` as
|
||||
`time.Duration`.
|
||||
2. Store config in barrier as `{mountPath}config.json`.
|
||||
3. Generate CA key pair:
|
||||
- `ed25519`: `ed25519.GenerateKey(rand.Reader)`
|
||||
- `ecdsa-p256`: `ecdsa.GenerateKey(elliptic.P256(), rand.Reader)`
|
||||
- `ecdsa-p384`: `ecdsa.GenerateKey(elliptic.P384(), rand.Reader)`
|
||||
4. Marshal private key to PEM using `x509.MarshalPKCS8PrivateKey` →
|
||||
`pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})`.
|
||||
5. Store private key PEM in barrier at `{mountPath}ca/key.pem`.
|
||||
6. Generate SSH public key via `ssh.NewPublicKey(pubKey)`, marshal with
|
||||
`ssh.MarshalAuthorizedKey`. Store at `{mountPath}ca/pubkey.pub`.
|
||||
7. Load key into memory: `ssh.NewSignerFromKey(caKey)` → `caSigner`.
|
||||
8. Initialize `krlVersion` to 0, store in barrier.
|
||||
|
||||
### Unseal
|
||||
|
||||
1. Load config from barrier.
|
||||
2. Load CA private key from barrier, parse into `crypto.PrivateKey`.
|
||||
3. Wrap as `ssh.Signer`.
|
||||
1. Load config JSON from barrier, unmarshal into `*SSHCAConfig`.
|
||||
2. Load `{mountPath}ca/key.pem` from barrier, decode PEM, parse with
|
||||
`x509.ParsePKCS8PrivateKey` → `caKey`.
|
||||
3. Create `caSigner` via `ssh.NewSignerFromKey(caKey)`.
|
||||
4. Load `krl_version.json` from barrier → `krlVersion`.
|
||||
|
||||
### Seal
|
||||
|
||||
1. Zeroize `caKey` (same `zeroizeKey` helper used by CA engine).
|
||||
1. Zeroize `caKey` using the shared `zeroizeKey` helper (see Implementation
|
||||
References below).
|
||||
2. Nil out `caSigner`, `config`.
|
||||
|
||||
## Operations
|
||||
@@ -85,6 +102,43 @@ Key material (`caKey`, `caSigner`) is zeroized on `Seal()`.
|
||||
| `revoke-cert` | Admin | Revoke a certificate (soft flag) |
|
||||
| `delete-cert` | Admin | Delete a certificate record |
|
||||
|
||||
### HandleRequest dispatch
|
||||
|
||||
Follow the CA engine's pattern (`internal/engine/ca/ca.go:284-317`):
|
||||
|
||||
```go
|
||||
func (e *SSHCAEngine) HandleRequest(ctx context.Context, req *engine.Request) (*engine.Response, error) {
|
||||
switch req.Operation {
|
||||
case "get-ca-pubkey":
|
||||
return e.handleGetCAPublicKey(ctx)
|
||||
case "sign-host":
|
||||
return e.handleSignHost(ctx, req)
|
||||
case "sign-user":
|
||||
return e.handleSignUser(ctx, req)
|
||||
case "create-profile":
|
||||
return e.handleCreateProfile(ctx, req)
|
||||
case "update-profile":
|
||||
return e.handleUpdateProfile(ctx, req)
|
||||
case "get-profile":
|
||||
return e.handleGetProfile(ctx, req)
|
||||
case "list-profiles":
|
||||
return e.handleListProfiles(ctx, req)
|
||||
case "delete-profile":
|
||||
return e.handleDeleteProfile(ctx, req)
|
||||
case "get-cert":
|
||||
return e.handleGetCert(ctx, req)
|
||||
case "list-certs":
|
||||
return e.handleListCerts(ctx, req)
|
||||
case "revoke-cert":
|
||||
return e.handleRevokeCert(ctx, req)
|
||||
case "delete-cert":
|
||||
return e.handleDeleteCert(ctx, req)
|
||||
default:
|
||||
return nil, fmt.Errorf("sshca: unknown operation: %s", req.Operation)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### sign-host
|
||||
|
||||
Request data:
|
||||
@@ -97,15 +151,30 @@ Request data:
|
||||
| `extensions` | No | Map of extensions to include |
|
||||
|
||||
Flow:
|
||||
1. Authenticate caller (`IsUser()`); admins bypass policy/ownership checks.
|
||||
2. Parse the supplied SSH public key.
|
||||
3. Generate a 64-bit serial using `crypto/rand`.
|
||||
4. Build `ssh.Certificate` with `CertType: ssh.HostCert`, principals, validity, serial.
|
||||
5. Policy check: `sshca/{mount}/id/{hostname}` for each principal, with ownership
|
||||
rules (same as CA engine — hostname not held by another user's active cert).
|
||||
6. Sign with `caSigner`.
|
||||
7. Store `CertRecord` in barrier (certificate bytes, metadata; **no private key**).
|
||||
8. Return signed certificate in OpenSSH format.
|
||||
1. Authenticate caller (`req.CallerInfo.IsUser()`); admins bypass policy checks.
|
||||
2. Parse the supplied SSH public key with `ssh.ParsePublicKey(ssh.ParseAuthorizedKey(...))`.
|
||||
3. Parse TTL: if provided parse as `time.Duration`, cap at `config.MaxTTL`.
|
||||
If not provided, use `config.DefaultTTL`.
|
||||
4. Policy check: for each hostname, check policy on
|
||||
`sshca/{mount}/id/{hostname}`, action `sign`. Use `req.CheckPolicy`.
|
||||
Fail early before generating a serial or building the cert.
|
||||
5. Generate a 64-bit serial: `var buf [8]byte; rand.Read(buf[:]); serial := binary.BigEndian.Uint64(buf[:])`.
|
||||
6. Build `ssh.Certificate`:
|
||||
```go
|
||||
cert := &ssh.Certificate{
|
||||
Key: parsedPubKey,
|
||||
Serial: serial,
|
||||
CertType: ssh.HostCert,
|
||||
KeyId: fmt.Sprintf("host:%s:%d", hostnames[0], serial),
|
||||
ValidPrincipals: hostnames,
|
||||
ValidAfter: uint64(time.Now().Unix()),
|
||||
ValidBefore: uint64(time.Now().Add(ttl).Unix()),
|
||||
Permissions: ssh.Permissions{Extensions: extensions},
|
||||
}
|
||||
```
|
||||
7. Sign: `cert.SignCert(rand.Reader, e.caSigner)`.
|
||||
8. Store `CertRecord` in barrier at `{mountPath}certs/{serial}.json`.
|
||||
9. Return: `{"certificate": ssh.MarshalAuthorizedKey(cert), "serial": serial}`.
|
||||
|
||||
### sign-user
|
||||
|
||||
@@ -126,20 +195,26 @@ setting security-sensitive options like `force-command` or `source-address`.
|
||||
Flow:
|
||||
1. Authenticate caller (`IsUser()`); admins bypass.
|
||||
2. Parse the supplied SSH public key.
|
||||
3. If `profile` is specified, load the signing profile and check policy
|
||||
(`sshca/{mount}/profile/{profile_name}`, action `read`). Merge the
|
||||
3. If `profile` is specified, load the signing profile from barrier and check
|
||||
policy (`sshca/{mount}/profile/{profile_name}`, action `read`). Merge the
|
||||
profile's critical options and extensions into the certificate. Any
|
||||
extensions in the request are merged with profile extensions; conflicts
|
||||
are resolved in favor of the profile.
|
||||
4. Generate a 64-bit serial using `crypto/rand`.
|
||||
5. Build `ssh.Certificate` with `CertType: ssh.UserCert`, principals, validity, serial.
|
||||
6. If the profile specifies `max_ttl`, enforce it (cap the requested TTL).
|
||||
7. Policy check: `sshca/{mount}/id/{principal}` for each principal.
|
||||
are resolved in favor of the profile. If the profile specifies
|
||||
`allowed_principals`, verify all requested principals are in the list.
|
||||
4. If the profile specifies `max_ttl`, enforce it (cap the requested TTL).
|
||||
5. Policy check: `sshca/{mount}/id/{principal}` for each principal, action `sign`.
|
||||
Default rule: a user can only sign certs for their own username as principal,
|
||||
unless a policy grants access to other principals.
|
||||
8. Sign with `caSigner`.
|
||||
9. Store `CertRecord` in barrier (includes profile name if used).
|
||||
10. Return signed certificate in OpenSSH format.
|
||||
unless a policy grants access to other principals. Implement by checking
|
||||
`req.CallerInfo.Username == principal` as the default-allow case.
|
||||
Fail early before generating a serial or building the cert.
|
||||
6. Generate a 64-bit serial using `crypto/rand`.
|
||||
7. Build `ssh.Certificate` with `CertType: ssh.UserCert`, principals, validity.
|
||||
8. Set `Permissions.CriticalOptions` from profile (if any) and
|
||||
`Permissions.Extensions` from merged extensions. Default extensions when
|
||||
none specified: `{"permit-pty": ""}`.
|
||||
9. Sign with `caSigner`.
|
||||
10. Store `CertRecord` in barrier (includes profile name if used).
|
||||
11. Return signed certificate in OpenSSH format + serial.
|
||||
|
||||
### Signing Profiles
|
||||
|
||||
@@ -151,11 +226,11 @@ options, and access to each profile is policy-gated.
|
||||
|
||||
```go
|
||||
type SigningProfile struct {
|
||||
Name string `json:"name"`
|
||||
CriticalOptions map[string]string `json:"critical_options"` // e.g. {"force-command": "/usr/bin/rsync", "source-address": "10.0.0.0/8"}
|
||||
Extensions map[string]string `json:"extensions"` // merged with request extensions
|
||||
MaxTTL string `json:"max_ttl,omitempty"` // overrides engine max_ttl if shorter
|
||||
AllowedPrincipals []string `json:"allowed_principals,omitempty"` // if set, restricts principals
|
||||
Name string `json:"name"`
|
||||
CriticalOptions map[string]string `json:"critical_options"` // e.g. {"force-command": "/usr/bin/rsync", "source-address": "10.0.0.0/8"}
|
||||
Extensions map[string]string `json:"extensions"` // merged with request extensions
|
||||
MaxTTL string `json:"max_ttl,omitempty"` // overrides engine max_ttl if shorter
|
||||
AllowedPrincipals []string `json:"allowed_principals,omitempty"` // if set, restricts principals
|
||||
}
|
||||
```
|
||||
|
||||
@@ -165,16 +240,6 @@ type SigningProfile struct {
|
||||
engine/sshca/{mount}/profiles/{name}.json
|
||||
```
|
||||
|
||||
#### Operations
|
||||
|
||||
| Operation | Auth Required | Description |
|
||||
|------------------|---------------|------------------------------------------|
|
||||
| `create-profile` | Admin | Create a signing profile |
|
||||
| `update-profile` | Admin | Update a signing profile |
|
||||
| `get-profile` | User/Admin | Get profile details |
|
||||
| `list-profiles` | User/Admin | List available profiles |
|
||||
| `delete-profile` | Admin | Delete a signing profile |
|
||||
|
||||
#### Policy Gating
|
||||
|
||||
Access to a profile is controlled via policy on resource
|
||||
@@ -194,7 +259,9 @@ type CertRecord struct {
|
||||
Serial uint64 `json:"serial"`
|
||||
CertType string `json:"cert_type"` // "host" or "user"
|
||||
Principals []string `json:"principals"`
|
||||
CertData string `json:"cert_data"` // OpenSSH format
|
||||
CertData string `json:"cert_data"` // OpenSSH authorized_keys format
|
||||
KeyID string `json:"key_id"` // certificate KeyId field
|
||||
Profile string `json:"profile,omitempty"` // signing profile used (if any)
|
||||
IssuedBy string `json:"issued_by"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
@@ -204,31 +271,71 @@ type CertRecord struct {
|
||||
}
|
||||
```
|
||||
|
||||
Serial is stored as `uint64` (not string) since SSH certificate serials are
|
||||
uint64 natively. Barrier path uses the decimal string representation:
|
||||
`fmt.Sprintf("%d", serial)`.
|
||||
|
||||
## Key Revocation List (KRL)
|
||||
|
||||
SSH servers cannot query Metacrypt in real time to check whether a certificate
|
||||
has been revoked. Instead, the SSH CA engine generates an OpenSSH-format KRL
|
||||
(Key Revocation List) that SSH servers fetch periodically and reference via
|
||||
`RevokedKeys` in `sshd_config`.
|
||||
has been revoked. Instead, the SSH CA engine generates a KRL that SSH servers
|
||||
fetch periodically and reference via `RevokedKeys` in `sshd_config`.
|
||||
|
||||
### KRL Generation
|
||||
### KRL Generation — Custom Implementation
|
||||
|
||||
The engine maintains a KRL in memory, rebuilt whenever a certificate is revoked
|
||||
or deleted. The KRL is a binary blob in OpenSSH KRL format
|
||||
(`golang.org/x/crypto/ssh` provides marshalling helpers), containing:
|
||||
**Important**: `golang.org/x/crypto/ssh` does **not** provide KRL generation
|
||||
helpers. It can parse KRLs but not build them. The engine must implement KRL
|
||||
serialization directly per the OpenSSH KRL format specification
|
||||
(`PROTOCOL.krl` in the OpenSSH source).
|
||||
|
||||
- **Serial revocations**: Revoked certificate serial numbers, keyed to the CA
|
||||
public key. This is the most compact representation.
|
||||
- **KRL version**: Monotonically increasing counter, incremented on each
|
||||
rebuild. SSH servers can use this to detect stale KRLs.
|
||||
- **Generated-at timestamp**: Included in the KRL for freshness checking.
|
||||
The KRL format is a binary structure:
|
||||
|
||||
The KRL is stored in the barrier at `engine/sshca/{mount}/krl.bin` and cached
|
||||
in memory. It is rebuilt on:
|
||||
- `revoke-cert` — adds the serial to the KRL.
|
||||
- `delete-cert` — if the cert was revoked, the KRL is regenerated from all
|
||||
remaining revoked certs.
|
||||
- Engine unseal — loaded from barrier into memory.
|
||||
```
|
||||
MAGIC = "OPENSSH_KRL\x00" (12 bytes)
|
||||
VERSION = uint32 (format version, always 1)
|
||||
KRL_VERSION = uint64 (monotonically increasing per rebuild)
|
||||
GENERATED_DATE = uint64 (Unix timestamp)
|
||||
FLAGS = uint64 (0)
|
||||
RESERVED = string (empty)
|
||||
COMMENT = string (empty)
|
||||
SECTIONS... (one or more typed sections)
|
||||
```
|
||||
|
||||
For serial-based revocation (the simplest and most compact representation):
|
||||
|
||||
```
|
||||
Section type: KRL_SECTION_CERTIFICATES (0x01)
|
||||
CA key blob: ssh.MarshalAuthorizedKey(caSigner.PublicKey())
|
||||
Subsection type: KRL_SECTION_CERT_SERIAL_LIST (0x20)
|
||||
Revoked serials: sorted list of uint64 serials
|
||||
```
|
||||
|
||||
Implement as a `buildKRL` function:
|
||||
|
||||
```go
|
||||
func (e *SSHCAEngine) buildKRL(revokedSerials []uint64) []byte {
|
||||
// 1. Sort serials.
|
||||
// 2. Write MAGIC header.
|
||||
// 3. Write KRL_VERSION (e.krlVersion), GENERATED_DATE (now), FLAGS (0).
|
||||
// 4. Write RESERVED (empty string), COMMENT (empty string).
|
||||
// 5. Write section header: type=0x01 (KRL_SECTION_CERTIFICATES).
|
||||
// 6. Write CA public key blob.
|
||||
// 7. Write subsection: type=0x20 (KRL_SECTION_CERT_SERIAL_LIST),
|
||||
// followed by each serial as uint64 big-endian.
|
||||
// 8. Return assembled bytes.
|
||||
}
|
||||
```
|
||||
|
||||
Use `encoding/binary` with `binary.BigEndian` for all integer encoding.
|
||||
SSH strings are length-prefixed: `uint32(len) + bytes`.
|
||||
|
||||
The KRL version counter is persisted in barrier at `{mountPath}krl_version.json`
|
||||
and incremented on each rebuild. On unseal, the counter is loaded from barrier.
|
||||
|
||||
The KRL is rebuilt (not stored in barrier — it's a derived artifact) on:
|
||||
- `revoke-cert` — collects all revoked serials, rebuilds.
|
||||
- `delete-cert` — if the cert was revoked, rebuilds from remaining revoked certs.
|
||||
- Engine unseal — rebuilds from all revoked certs.
|
||||
|
||||
### Distribution
|
||||
|
||||
@@ -237,13 +344,12 @@ unauthenticated endpoint (analogous to the public CA key endpoint):
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|-------------------------------------|--------------------------------|
|
||||
| GET | `/v1/sshca/{mount}/krl` | Current KRL (binary, OpenSSH format) |
|
||||
| GET | `/v1/sshca/{mount}/krl` | Current KRL (binary) |
|
||||
|
||||
The response includes:
|
||||
- `Content-Type: application/octet-stream`
|
||||
- `ETag` header derived from the KRL version, enabling conditional fetches.
|
||||
- `Cache-Control: max-age=60` to encourage periodic refresh without
|
||||
overwhelming the server.
|
||||
- `ETag` header: `fmt.Sprintf("%d", e.krlVersion)`, enabling conditional fetches.
|
||||
- `Cache-Control: max-age=60` to encourage periodic refresh.
|
||||
|
||||
SSH servers should be configured to fetch the KRL on a cron schedule (e.g.
|
||||
every 1–5 minutes) and write it to a local file referenced by `sshd_config`:
|
||||
@@ -252,19 +358,6 @@ every 1–5 minutes) and write it to a local file referenced by `sshd_config`:
|
||||
RevokedKeys /etc/ssh/metacrypt_krl
|
||||
```
|
||||
|
||||
A helper script or systemd timer can fetch the KRL:
|
||||
|
||||
```bash
|
||||
curl -s -o /etc/ssh/metacrypt_krl \
|
||||
https://metacrypt.example.com:8443/v1/sshca/ssh/krl
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation | Auth Required | Description |
|
||||
|------------|---------------|----------------------------------------------|
|
||||
| `get-krl` | None | Return the current KRL in OpenSSH format |
|
||||
|
||||
## gRPC Service (proto/metacrypt/v2/sshca.proto)
|
||||
|
||||
```protobuf
|
||||
@@ -292,7 +385,7 @@ Public (unseal required, no auth):
|
||||
| Method | Path | Description |
|
||||
|--------|-------------------------------------|--------------------------------|
|
||||
| GET | `/v1/sshca/{mount}/ca` | CA public key (SSH format) |
|
||||
| GET | `/v1/sshca/{mount}/krl` | Current KRL (OpenSSH format) |
|
||||
| GET | `/v1/sshca/{mount}/krl` | Current KRL (binary) |
|
||||
|
||||
Typed endpoints (auth required):
|
||||
|
||||
@@ -305,12 +398,96 @@ Typed endpoints (auth required):
|
||||
| GET | `/v1/sshca/{mount}/profiles/{name}` | Get profile |
|
||||
| PUT | `/v1/sshca/{mount}/profiles/{name}` | Update profile |
|
||||
| DELETE | `/v1/sshca/{mount}/profiles/{name}` | Delete profile |
|
||||
| GET | `/v1/sshca/{mount}/certs` | List cert records |
|
||||
| GET | `/v1/sshca/{mount}/cert/{serial}` | Get cert record |
|
||||
| POST | `/v1/sshca/{mount}/cert/{serial}/revoke` | Revoke cert |
|
||||
| DELETE | `/v1/sshca/{mount}/cert/{serial}` | Delete cert record |
|
||||
|
||||
### REST Route Registration
|
||||
|
||||
Add to `internal/server/routes.go` in `registerRoutes`, following the CA
|
||||
engine's pattern with `chi.URLParam`:
|
||||
|
||||
```go
|
||||
// SSH CA public routes (no auth, unseal required).
|
||||
r.Get("/v1/sshca/{mount}/ca", s.requireUnseal(s.handleSSHCAPublicKey))
|
||||
r.Get("/v1/sshca/{mount}/krl", s.requireUnseal(s.handleSSHCAKRL))
|
||||
|
||||
// SSH CA typed routes (auth required).
|
||||
r.Post("/v1/sshca/{mount}/sign-host", s.requireAuth(s.handleSSHCASignHost))
|
||||
r.Post("/v1/sshca/{mount}/sign-user", s.requireAuth(s.handleSSHCASignUser))
|
||||
r.Post("/v1/sshca/{mount}/profiles", s.requireAdmin(s.handleSSHCACreateProfile))
|
||||
r.Get("/v1/sshca/{mount}/profiles", s.requireAuth(s.handleSSHCAListProfiles))
|
||||
r.Get("/v1/sshca/{mount}/profiles/{name}", s.requireAuth(s.handleSSHCAGetProfile))
|
||||
r.Put("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCAUpdateProfile))
|
||||
r.Delete("/v1/sshca/{mount}/profiles/{name}", s.requireAdmin(s.handleSSHCADeleteProfile))
|
||||
r.Get("/v1/sshca/{mount}/certs", s.requireAuth(s.handleSSHCAListCerts))
|
||||
r.Get("/v1/sshca/{mount}/cert/{serial}", s.requireAuth(s.handleSSHCAGetCert))
|
||||
r.Post("/v1/sshca/{mount}/cert/{serial}/revoke", s.requireAdmin(s.handleSSHCARevokeCert))
|
||||
r.Delete("/v1/sshca/{mount}/cert/{serial}", s.requireAdmin(s.handleSSHCADeleteCert))
|
||||
```
|
||||
|
||||
Each handler extracts `chi.URLParam(r, "mount")`, builds an `engine.Request`
|
||||
with the appropriate operation name and data, and calls
|
||||
`s.engines.HandleRequest(...)`. Follow the `handleGetCert`/`handleRevokeCert`
|
||||
pattern in the existing code.
|
||||
|
||||
All operations are also accessible via the generic `POST /v1/engine/request`.
|
||||
|
||||
### gRPC Interceptor Maps
|
||||
|
||||
Add to `sealRequiredMethods`, `authRequiredMethods`, and `adminRequiredMethods`
|
||||
in `internal/grpcserver/server.go`:
|
||||
|
||||
```go
|
||||
// sealRequiredMethods:
|
||||
"/metacrypt.v2.SSHCAService/GetCAPublicKey": true,
|
||||
"/metacrypt.v2.SSHCAService/SignHost": true,
|
||||
"/metacrypt.v2.SSHCAService/SignUser": true,
|
||||
"/metacrypt.v2.SSHCAService/CreateProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/GetProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/ListProfiles": true,
|
||||
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/GetCert": true,
|
||||
"/metacrypt.v2.SSHCAService/ListCerts": true,
|
||||
"/metacrypt.v2.SSHCAService/RevokeCert": true,
|
||||
"/metacrypt.v2.SSHCAService/DeleteCert": true,
|
||||
"/metacrypt.v2.SSHCAService/GetKRL": true,
|
||||
|
||||
// authRequiredMethods (all except GetCAPublicKey and GetKRL):
|
||||
"/metacrypt.v2.SSHCAService/SignHost": true,
|
||||
"/metacrypt.v2.SSHCAService/SignUser": true,
|
||||
"/metacrypt.v2.SSHCAService/CreateProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/GetProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/ListProfiles": true,
|
||||
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/GetCert": true,
|
||||
"/metacrypt.v2.SSHCAService/ListCerts": true,
|
||||
"/metacrypt.v2.SSHCAService/RevokeCert": true,
|
||||
"/metacrypt.v2.SSHCAService/DeleteCert": true,
|
||||
|
||||
// adminRequiredMethods:
|
||||
"/metacrypt.v2.SSHCAService/CreateProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/UpdateProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/DeleteProfile": true,
|
||||
"/metacrypt.v2.SSHCAService/RevokeCert": true,
|
||||
"/metacrypt.v2.SSHCAService/DeleteCert": true,
|
||||
```
|
||||
|
||||
Also add SSH CA operations to `adminOnlyOperations` in `routes.go` (keys are
|
||||
`engineType:operation` to avoid cross-engine name collisions):
|
||||
|
||||
```go
|
||||
// SSH CA engine.
|
||||
"sshca:create-profile": true,
|
||||
"sshca:update-profile": true,
|
||||
"sshca:delete-profile": true,
|
||||
"sshca:revoke-cert": true,
|
||||
"sshca:delete-cert": true,
|
||||
```
|
||||
|
||||
## Web UI
|
||||
|
||||
Add to `/dashboard` the ability to mount an SSH CA engine.
|
||||
@@ -322,20 +499,54 @@ Add an `/sshca` page (or section on the existing PKI page) displaying:
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **`internal/engine/sshca/`** — Implement `SSHCAEngine` (types, lifecycle,
|
||||
operations). Reuse `zeroizeKey` from `internal/engine/ca/` (move to shared
|
||||
helper or duplicate).
|
||||
2. **Register factory** in `cmd/metacrypt/main.go`:
|
||||
`registry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine)`.
|
||||
3. **Proto definitions** — `proto/metacrypt/v2/sshca.proto`, run `make proto`.
|
||||
4. **gRPC handlers** — `internal/grpcserver/sshca.go`.
|
||||
5. **REST routes** — Add to `internal/server/routes.go`.
|
||||
6. **Web UI** — Add template + webserver routes.
|
||||
7. **Tests** — Unit tests with in-memory barrier following the CA test pattern.
|
||||
1. **Move `zeroizeKey` to shared location**: Copy the `zeroizeKey` function
|
||||
from `internal/engine/ca/ca.go` (lines 1481–1498) to a new file
|
||||
`internal/engine/helpers.go` in the `engine` package. Export it as
|
||||
`engine.ZeroizeKey`. Update the CA engine to call `engine.ZeroizeKey`
|
||||
instead of its local copy. This avoids a circular import (sshca cannot
|
||||
import ca).
|
||||
|
||||
2. **`internal/engine/sshca/`** — Implement `SSHCAEngine`:
|
||||
- `types.go` — `SSHCAConfig`, `CertRecord`, `SigningProfile` structs.
|
||||
- `sshca.go` — `NewSSHCAEngine` factory, lifecycle methods (`Type`,
|
||||
`Initialize`, `Unseal`, `Seal`), `HandleRequest` dispatch.
|
||||
- `sign.go` — `handleSignHost`, `handleSignUser`.
|
||||
- `profiles.go` — Profile CRUD handlers.
|
||||
- `certs.go` — `handleGetCert`, `handleListCerts`, `handleRevokeCert`,
|
||||
`handleDeleteCert`.
|
||||
- `krl.go` — `buildKRL`, `rebuildKRL`, `handleGetKRL`,
|
||||
`collectRevokedSerials`.
|
||||
|
||||
3. **Register factory** in `cmd/metacrypt/server.go` (line 76):
|
||||
```go
|
||||
engineRegistry.RegisterFactory(engine.EngineTypeSSHCA, sshca.NewSSHCAEngine)
|
||||
```
|
||||
|
||||
4. **Proto definitions** — `proto/metacrypt/v2/sshca.proto`, run `make proto`.
|
||||
|
||||
5. **gRPC handlers** — `internal/grpcserver/sshca.go`. Follow
|
||||
`internal/grpcserver/ca.go` pattern: `sshcaServer` struct wrapping
|
||||
`GRPCServer`, helper function for error mapping, typed RPC methods.
|
||||
Register with `pb.RegisterSSHCAServiceServer(s.srv, &sshcaServer{s: s})`
|
||||
in `server.go`.
|
||||
|
||||
6. **REST routes** — Add to `internal/server/routes.go` per the route
|
||||
registration section above.
|
||||
|
||||
7. **Tests** — `internal/engine/sshca/sshca_test.go`: unit tests with
|
||||
in-memory barrier following the CA test pattern. Test:
|
||||
- Initialize + unseal lifecycle
|
||||
- sign-host: valid signing, TTL enforcement, serial uniqueness
|
||||
- sign-user: own-principal default, profile merging, profile TTL cap
|
||||
- Profile CRUD
|
||||
- Certificate list/get/revoke/delete
|
||||
- KRL rebuild correctness (revoked serials present, unrevoked absent)
|
||||
- Seal zeroizes key material
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `golang.org/x/crypto/ssh` (already in `go.mod` via transitive deps)
|
||||
- `encoding/binary` (stdlib, for KRL serialization)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
@@ -351,3 +562,19 @@ Add an `/sshca` page (or section on the existing PKI page) displaying:
|
||||
prevents unprivileged users from bypassing `sshd_config` restrictions.
|
||||
- Profile access is policy-gated: a user must have policy access to
|
||||
`sshca/{mount}/profile/{name}` to use a profile.
|
||||
- RSA keys are excluded to reduce attack surface and simplify the implementation.
|
||||
|
||||
## Implementation References
|
||||
|
||||
These existing code patterns should be followed exactly:
|
||||
|
||||
| Pattern | Reference File | Lines |
|
||||
|---------|---------------|-------|
|
||||
| HandleRequest switch dispatch | `internal/engine/ca/ca.go` | 284–317 |
|
||||
| zeroizeKey helper | `internal/engine/ca/ca.go` | 1481–1498 |
|
||||
| CertRecord storage (JSON in barrier) | `internal/engine/ca/ca.go` | cert storage pattern |
|
||||
| REST route registration with chi | `internal/server/routes.go` | 38–50 |
|
||||
| gRPC handler structure | `internal/grpcserver/ca.go` | full file |
|
||||
| gRPC interceptor maps | `internal/grpcserver/server.go` | 107–192 |
|
||||
| Engine factory registration | `cmd/metacrypt/server.go` | 76 |
|
||||
| adminOnlyOperations map | `internal/server/routes.go` | 259–279 |
|
||||
|
||||
Reference in New Issue
Block a user