From 1426d8e1de1e469befd9a0953965d4ef3653c616 Mon Sep 17 00:00:00 2001 From: James Hartig Date: Fri, 21 Aug 2015 22:44:00 -0400 Subject: [PATCH] WIP: Provide OCSP verification endpoint and persist certs to disk --- README.md | 52 ++++++++++- certs/certs.go | 152 +++++++++++++++++++++++++++++++ cfssl/cfssl.go | 40 ++++++--- config/config.go | 46 ++++++++-- main.go | 227 +++++++++++++++++++++++++++++++++++++++++------ ocsp/ocsp.go | 106 ++++++++++++++++++++++ 6 files changed, 576 insertions(+), 47 deletions(-) create mode 100644 certs/certs.go create mode 100644 ocsp/ocsp.go diff --git a/README.md b/README.md index 499e49e..ca64b64 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,25 @@ $ echo -n '{"Hostname": "test@yourremote.com"}' | openssl dgst -sha256 -hmac "yo 10ded103a220f14f02f9ee106a32348b1d0105cc8c40aa1c99ef1f115542a2ff ``` +For `/list` and `/revoke` an optional `--admin-key` can be sent which should differ from `--key`. For those methods +you should instead use that key. By default `--admin-key` defaults to `--key`. + +## OCSP + +In addition to generating certs, locksmith can also be a OCSP responder. In your cfssl config, add +``` +"ocsp_url": "https://locksmithIP:port/ocsp-verify", +``` +to each profile you want to use ocsp verification. You'll also need to have a responses file and pass it to locksmith +via `--ocsp-responses-file`. The responses file should be base64-encoded responses separated by a space (or newline) +character. By default, generated certificates are NOT automatically added to the ocsp responses file. To add them +automatically, pass `--auto-ocsp-sign` to locksmith. The `/ocsp-verify` endpoint does not accept an HMAC signature. + +In order to sign OCSP responses in cfssl you will need to generate a OCSP certificate/key and pass it to cfssl as +`-responder` and `-responder-key`. + +Todo: add instructions for how to incorporate this into OpenSSL server + ## Endpoints ### /generate @@ -50,7 +69,7 @@ Generates a new certificate #### Params * `hostname` [string] the hostname to set as the CN for the certificate -* `rimestamp` [int] the current unix timestamp in seconds +* `timestamp` [int] the current unix timestamp in seconds * `profile` [string|optional] the profile to send to cfssl * `request` [hash|optional] the csr request to send to cfssl (optional if `--default-name-file` was specified) * `CN` [string|optional] common name for the certificate (defaults to `hostname`) @@ -60,4 +79,33 @@ Generates a new certificate #### Result -Returns a ovpn file to save as `client.conf` (on Linux) and use with OpenVPN client +Returns the contents of a new ovpn file to save as `client.conf` (on Linux) and use with OpenVPN client. +*This does not return json* + + +### /list + +Returns a list of previously generated certificates (assuming you passed `--certs-file`) + +#### Params +* `timestamp` [int] the current unix timestamp in seconds + +#### Result + +Returns an array of certificates. Each certificate looks like: +``` +{"hostname":"laptop","remoteAddr":"10.0.0.1","generated_at":1440113442,"certificate":"MIIEWD..."} + + +### /revoke + +Generates a new certificate + +#### Params +* `certificate` [string] base64 of certificate der, or pem-encoded certificate +* `reason` [int] the reason code for the revocation +* `timestamp` [int] the current unix timestamp in seconds + +#### Result + +Returns `{"success": true}` or non-200 status code. diff --git a/certs/certs.go b/certs/certs.go new file mode 100644 index 0000000..dd4ed28 --- /dev/null +++ b/certs/certs.go @@ -0,0 +1,152 @@ +// Package certs handles persisting and loading certs +package certs + +import ( + "bufio" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "github.com/levenlabs/locksmith/config" + "log" + "os" + "time" +) + +type saveReq struct { + Hostname string + RemoteAddr string + Certificate string + ReplyCh chan error +} + +type Certificate struct { + Hostname string `json:"hostname"` + RemoteAddr string `json:"remoteAddr"` + Generated int64 `json:"generated_at"` + Certificate string `json:"certificate"` +} + +type listReq struct { + ReplyCh chan []Certificate +} + +var ( + listCh = make(chan listReq) + saveCh = make(chan saveReq) + timeFmt = "2006-01-02T15:04:05" + fileFmt = "%s | %s | %s | %s" +) + +func init() { + if config.CertsFile != "" { + // make sure we can write to the file + f, err := os.OpenFile(config.CertsFile, os.O_CREATE, 0600) + if err != nil { + log.Fatalf("Failed to create certs file: %s", err) + } + f.Close() + } + + go func() { + var l listReq + var s saveReq + for { + select { + case l = <-listCh: + l.ReplyCh <- listCerts() + case s = <-saveCh: + s.ReplyCh <- saveCert(s.Hostname, s.RemoteAddr, s.Certificate) + } + } + }() +} + +func SaveCert(hostname, remoteAddr, cert string) error { + r := make(chan error) + saveCh <- saveReq{ + Hostname: hostname, + RemoteAddr: remoteAddr, + Certificate: cert, + ReplyCh: r, + } + return <-r +} + +func saveCert(hostname, remoteAddr, cert string) error { + if config.CertsFile == "" { + return nil + } + + b, _ := pem.Decode([]byte(cert)) + if b == nil || len(b.Bytes) == 0 { + return errors.New("Error reading PEM certificate") + } + + b64cert := base64.StdEncoding.EncodeToString(b.Bytes) + tf := time.Now().Format(timeFmt) + line := fmt.Sprintf(fileFmt, tf, hostname, remoteAddr, b64cert) + + f, err := os.OpenFile(config.CertsFile, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + return err + } + + defer f.Close() + line += "\n" + if _, err = f.WriteString(line); err != nil { + return err + } + return nil +} + +func ListCerts() []Certificate { + r := make(chan []Certificate) + listCh <- listReq{ + ReplyCh: r, + } + return <-r +} + +func listCerts() []Certificate { + // give this some starting capacity + c := make([]Certificate, 0, 16) + if config.CertsFile == "" { + return c + } + f, err := os.OpenFile(config.CertsFile, os.O_RDONLY, 0600) + if err != nil { + log.Printf("Error reading certs file: %s", err) + return c + } + defer f.Close() + + s := bufio.NewScanner(f) + var l string + var cert Certificate + var t string + var gen time.Time + for s.Scan() { + l = s.Text() + if l == "" { + continue + } + cert = Certificate{} + _, err = fmt.Sscanf(l, fileFmt, &t, &cert.Hostname, &cert.RemoteAddr, &cert.Certificate) + if err != nil { + log.Printf("Error reading line from certs file: %s", err) + continue + } + gen, err = time.Parse(timeFmt, t) + if err != nil { + log.Printf("Error parsing time in certs file: %s", err) + continue + } + cert.Generated = gen.Unix() + c = append(c, cert) + } + if err = s.Err(); err != nil { + log.Printf("Error scanning certs file: %s", err) + } + return c +} diff --git a/cfssl/cfssl.go b/cfssl/cfssl.go index b5ecdf6..8a2c595 100644 --- a/cfssl/cfssl.go +++ b/cfssl/cfssl.go @@ -9,10 +9,8 @@ import ( "github.com/levenlabs/locksmith/config" "io/ioutil" "log" - "math" "net/http" "regexp" - "time" ) var defaultName *csrName @@ -63,6 +61,13 @@ type GenerateRequest struct { Timestamp int64 `json:"timestamp,omitempty"` } +type OCSPSignRequest struct { + Certificate string `json:"certificate"` + Status string `json:"status,omitempty"` + Reason int `json:"reason,omitempty"` + RevokedAt string `json:"revoked_at,omitempty"` +} + func init() { if config.CFSSLAddr == "" { log.Fatal("--cfssl-addr must be sent") @@ -87,14 +92,6 @@ func GenerateCert(req *GenerateRequest, remoteAddr string) (cert string, key str err = errors.New("Invalid hostname sent") return } - if config.TimestampDrift > 0 { - now := time.Now().UTC().Unix() - diff := math.Abs(float64(now - req.Timestamp)) - if diff > config.TimestampDrift { - err = errors.New("Timestamp sent is outside of drift range") - return - } - } // we can't check if Key == nil because Key isn't a pointer but we can check for size not existing if req.Request.Key.Size == 0 { req.Request.Key = *defaultKey @@ -117,7 +114,6 @@ func GenerateCert(req *GenerateRequest, remoteAddr string) (cert string, key str r.Request = req.Request r.Profile = req.Profile r.RemoteAddress = remoteAddr - r.Token = config.CFSSLKey r.Timestamp = req.Timestamp j, err := json.Marshal(r) @@ -142,6 +138,28 @@ func GenerateCert(req *GenerateRequest, remoteAddr string) (cert string, key str return } +func SignOCSPResponse(req *OCSPSignRequest) (resp string, err error) { + if req.Certificate == "" { + err = errors.New("Invalid certificate sent") + return + } + j, err := json.Marshal(req) + if err != nil { + return + } + res, err := request("/api/v1/cfssl/ocspsign", j) + if err != nil { + return + } + + var ok bool + if resp, ok = res.Result["ocspResponse"].(string); !ok { + err = fmt.Errorf("Missing certificate from ocspsign req %v", res.Result) + return + } + return +} + func request(path string, req []byte) (*cfsslResult, error) { url := config.CFSSLAddr + path if matched, _ := regexp.Match("/^https?://.*", []byte(url)); !matched { diff --git a/config/config.go b/config/config.go index 32473cd..0733f0e 100644 --- a/config/config.go +++ b/config/config.go @@ -3,18 +3,23 @@ // packages. package config -import "github.com/mediocregopher/lever" +import ( + "github.com/mediocregopher/lever" +) // Configurable variables which are made available var ( InternalAPIAddr string HMACKey []byte + HMACAdminKey []byte OVPNTemplateFile string CAFile string CFSSLAddr string - CFSSLKey string DefaultNameFile string TimestampDrift float64 + CertsFile string + OCSPRespFile string + AutoOCSP bool ) func init() { @@ -26,7 +31,12 @@ func init() { }) l.Add(lever.Param{ Name: "--key", - Description: "HMAC key for incoming requests", + Description: "HMAC key for incoming requests to /generate", + Default: "", + }) + l.Add(lever.Param{ + Name: "--admin-key", + Description: "HMAC key for incoming requests to /revoke /list (defaults to --key)", Default: "", }) l.Add(lever.Param{ @@ -44,11 +54,6 @@ func init() { Description: "Address to cfssl server", Default: "127.0.0.1:8888", }) - l.Add(lever.Param{ - Name: "--cfssl-auth", - Description: "Auth Key to create certificates in cfssl", - Default: "", - }) l.Add(lever.Param{ Name: "--default-name-file", Description: "Default name params in a json file", @@ -59,16 +64,39 @@ func init() { Description: "Maximum allowed timestamp drift in seconds (0 to disable checking)", Default: "10", }) + l.Add(lever.Param{ + Name: "--certs-file", + Description: "Save/Read certificates to/from a file", + Default: "", + }) + l.Add(lever.Param{ + Name: "--ocsp-responses-file", + Description: "OCSP responses file to serve from and update", + Default: "", + }) + l.Add(lever.Param{ + Name: "--auto-ocsp-sign", + Description: "Automatically OCSP sign new certificates", + Flag: true, + }) l.Parse() InternalAPIAddr, _ = l.ParamStr("--internal-addr") k, _ := l.ParamStr("--key") HMACKey = []byte(k) + HMACAdminKey = HMACKey + k, _ = l.ParamStr("--admin-key") + if len(k) > 0 { + HMACAdminKey = []byte(k) + } OVPNTemplateFile, _ = l.ParamStr("--ovpn-template") CAFile, _ = l.ParamStr("--ca-file") CFSSLAddr, _ = l.ParamStr("--cfssl-addr") - CFSSLKey, _ = l.ParamStr("--cfssl-auth") DefaultNameFile, _ = l.ParamStr("--default-name-file") td, _ := l.ParamInt("--timestamp-drift") TimestampDrift = float64(td) + CertsFile, _ = l.ParamStr("--certs-file") + OCSPRespFile, _ = l.ParamStr("--ocsp-responses-file") + AutoOCSP = l.ParamFlag("--auto-ocsp-sign") + } diff --git a/main.go b/main.go index f47f010..55b25f6 100644 --- a/main.go +++ b/main.go @@ -3,24 +3,47 @@ package main import ( "crypto/hmac" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" + "encoding/pem" + "github.com/levenlabs/locksmith/certs" "github.com/levenlabs/locksmith/cfssl" "github.com/levenlabs/locksmith/config" + "github.com/levenlabs/locksmith/ocsp" "github.com/levenlabs/locksmith/ovpn" "io/ioutil" "log" + "math" + "net" "net/http" + "time" ) +type genericReq struct { + Timestamp int64 `json:"timestamp,omitempty"` +} + +type revokeReq struct { + Certificate string `json:"certificate"` + Reason int `json:"reason,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` +} + type OVPNContents struct { CA []byte } func main() { - log.Printf("Listening on %s", config.InternalAPIAddr) - http.HandleFunc("/generate", generateHandler) + http.HandleFunc("/list", listHandler) + http.HandleFunc("/revoke", revokeHandler) + + if h := ocsp.GetHandler(); h != nil { + http.Handle("/ocsp-verify", h) + } + + log.Printf("Listening on %s", config.InternalAPIAddr) log.Fatal(http.ListenAndServe(config.InternalAPIAddr, nil)) } @@ -30,51 +53,205 @@ func generateHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Invalid HTTP Method", http.StatusMethodNotAllowed) return } - body, err := ioutil.ReadAll(r.Body) - if err != nil || len(body) == 0 { + body := readAndVerifyBody(w, r, "generate", config.HMACKey) + if body == nil { + return + } + + var c cfssl.GenerateRequest + err := json.Unmarshal(body, &c) + if err != nil { http.Error(w, "Invalid POST Body", http.StatusBadRequest) return } - if len(config.HMACKey) > 0 { - sig := r.URL.Query().Get("sig") - if sig == "" { - http.Error(w, "Invalid Request", http.StatusBadRequest) - return + if !verifyTimestamp(w, c.Timestamp) { + return + } + + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + log.Printf("net.SplitHostPort(%s) -> %s", r.RemoteAddr, err) + http.Error(w, "Invalid RemoteAddr", http.StatusBadRequest) + return + } + + cert, key, err := cfssl.GenerateCert(&c, ip) + if err != nil { + log.Printf("cfssl.GenerateCert(%#v) -> %s", c, err) + http.Error(w, "Invalid Request", http.StatusBadRequest) + return + } + + err = certs.SaveCert(c.Hostname, ip, cert) + if err != nil { + log.Printf("SaveCert error: %s", err) + http.Error(w, "Error saving certificate file", http.StatusInternalServerError) + return + } + + if config.AutoOCSP { + ocspReq := &cfssl.OCSPSignRequest{ + Certificate: cert, + Status: "good", } - rawSig, err := hex.DecodeString(sig) + resp, err := cfssl.SignOCSPResponse(ocspReq) if err != nil { - http.Error(w, "Invalid Request", http.StatusBadRequest) + log.Printf("SignOCSPResponse error: %s", err) + http.Error(w, "Error signing ocsp response", http.StatusInternalServerError) return } - if !verifyHMAC(body, rawSig) { - http.Error(w, "Invalid HMAC Sig", http.StatusBadRequest) + err = ocsp.RecordNewResponse(resp) + if err != nil { + log.Printf("RecordNewResponse error: %s", err) + http.Error(w, "Error recording ocsp response", http.StatusInternalServerError) return } } - var c cfssl.GenerateRequest - err = json.Unmarshal(body, &c) + + err = ovpn.CreateWrite(w, cert, key) + if err != nil { + log.Printf("ovpn.CreateWrite -> %s", err) + http.Error(w, "Error creating ovpn file", http.StatusInternalServerError) + return + } + log.Printf("Generated: %s %s", c.Hostname, ip) +} + +func listHandler(w http.ResponseWriter, r *http.Request) { + log.Printf("Request: %s %s %s", r.Method, r.RemoteAddr, r.RequestURI) + if r.Method != "POST" { + http.Error(w, "Invalid HTTP Method", http.StatusMethodNotAllowed) + return + } + body := readAndVerifyBody(w, r, "list", config.HMACAdminKey) + if body == nil { + return + } + + var c genericReq + err := json.Unmarshal(body, &c) if err != nil { http.Error(w, "Invalid POST Body", http.StatusBadRequest) return } - cert, key, err := cfssl.GenerateCert(&c, r.RemoteAddr) + if !verifyTimestamp(w, c.Timestamp) { + return + } + + certs := certs.ListCerts() + resp, err := json.Marshal(certs) if err != nil { - log.Printf("cfssl.GenerateCert(%#v) -> %s", c, err) - http.Error(w, "Invalid Request", http.StatusBadRequest) + log.Printf("json.Marshal -> %s", err) + http.Error(w, "Error creating json response", http.StatusInternalServerError) return } - err = ovpn.CreateWrite(w, cert, key) + + w.Header().Set("Content-Type", "application/json") + w.Write(resp) +} + +func revokeHandler(w http.ResponseWriter, r *http.Request) { + log.Printf("Request: %s %s %s", r.Method, r.RemoteAddr, r.RequestURI) + if r.Method != "POST" { + http.Error(w, "Invalid HTTP Method", http.StatusMethodNotAllowed) + return + } + body := readAndVerifyBody(w, r, "list", config.HMACAdminKey) + if body == nil { + return + } + + var c revokeReq + err := json.Unmarshal(body, &c) if err != nil { - log.Printf("ovpn.CreateWrite -> %s", err) - http.Error(w, "Error creating ovpn file", http.StatusInternalServerError) + http.Error(w, "Invalid POST Body", http.StatusBadRequest) + return + } + if !verifyTimestamp(w, c.Timestamp) { + return + } + + cert := c.Certificate + // try to base64 decode the certificate first + decoded, err := base64.StdEncoding.DecodeString(cert) + if err == nil { + cert = string(decoded) + } + + // make sure the certificate is in PEM format + p, _ := pem.Decode([]byte(cert)) + if p == nil { + // since it wasn't in PEM format, put it in PEM format + cert = string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: []byte(cert), + })) + } + + now := time.Now() + ocspReq := &cfssl.OCSPSignRequest{ + Certificate: cert, + Status: "revoked", + Reason: c.Reason, + RevokedAt: now.Format("2006-01-02"), + } + resp, err := cfssl.SignOCSPResponse(ocspReq) + if err != nil { + log.Printf("SignOCSPResponse revoke error: %s", err) + http.Error(w, "Error signing ocsp response", http.StatusInternalServerError) + return + } + err = ocsp.RecordNewResponse(resp) + if err != nil { + log.Printf("RecordNewResponse revoke error: %s", err) + http.Error(w, "Error recording ocsp response", http.StatusInternalServerError) return } - log.Printf("Generated: %s %s", c.Hostname, r.RemoteAddr) + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("{\"success\":true}")) +} + +func readAndVerifyBody(w http.ResponseWriter, r *http.Request, method string, key []byte) []byte { + // the body is optional since they can make GET requests + body, err := ioutil.ReadAll(r.Body) + if err != nil || len(body) == 0 { + http.Error(w, "Invalid POST Body", http.StatusBadRequest) + return nil + } + if len(key) > 0 { + sig := r.URL.Query().Get("sig") + if sig == "" { + http.Error(w, "Invalid Request", http.StatusBadRequest) + return nil + } + if !verifyHMAC(method, body, sig, key) { + http.Error(w, "Invalid HMAC Sig", http.StatusBadRequest) + return nil + } + } + return body } -func verifyHMAC(body []byte, sentMac []byte) bool { - mac := hmac.New(sha256.New, config.HMACKey) +func verifyHMAC(method string, body []byte, sentSig string, key []byte) bool { + rawSig, err := hex.DecodeString(sentSig) + if err != nil { + return false + } + mac := hmac.New(sha256.New, key) mac.Write(body) expectedMAC := mac.Sum(nil) - return hmac.Equal(sentMac, expectedMAC) + return hmac.Equal(rawSig, expectedMAC) +} + +func verifyTimestamp(w http.ResponseWriter, ts int64) bool { + if config.TimestampDrift > 0 { + now := time.Now().UTC().Unix() + diff := math.Abs(float64(now - ts)) + if diff > config.TimestampDrift { + http.Error(w, "Timestamp sent is outside of drift range", http.StatusBadRequest) + return false + } + } + return true } diff --git a/ocsp/ocsp.go b/ocsp/ocsp.go new file mode 100644 index 0000000..1741cff --- /dev/null +++ b/ocsp/ocsp.go @@ -0,0 +1,106 @@ +// Package ocsp handles responding to ocsp requests and saving new ones +package ocsp + +import ( + "github.com/levenlabs/locksmith/config" + "log" + + "encoding/base64" + "errors" + "fmt" + "github.com/cloudflare/cfssl/ocsp" + goocsp "golang.org/x/crypto/ocsp" + "net/http" + "os" +) + +type recordReq struct { + Response string + ReplyCh chan error +} + +var responder *ocsp.Responder +var recordCh chan recordReq + +func init() { + if config.OCSPRespFile != "" { + src, err := ocsp.NewSourceFromFile(config.OCSPRespFile) + if err != nil { + log.Fatalf("Failed to read ocsp responses file: %s", err) + } + responder = &ocsp.Responder{Source: src} + } + + recordCh = make(chan recordReq) + go func() { + for req := range recordCh { + req.ReplyCh <- recordNewResponse(req.Response) + } + }() +} + +func GetHandler() http.Handler { + if responder == nil { + return nil + } + return *responder +} + +func ReloadResponses() { + src, err := ocsp.NewSourceFromFile(config.OCSPRespFile) + if err != nil { + log.Printf("Failed to reload ocsp responses file: %s", err) + return + } + responder.Source = src +} + +func RecordNewResponse(resp string) error { + r := make(chan error) + recordCh <- recordReq{ + Response: resp, + ReplyCh: r, + } + return <-r +} + +func recordNewResponse(resp string) error { + if responder == nil { + return errors.New("No --ocsp-responses-file was sent cannot record new response") + } + //check to see if resp is base64 encoded or not + decodedResp := []byte(resp) + der, err := base64.StdEncoding.DecodeString(resp) + if err == nil { + decodedResp = der + } + + r, err := goocsp.ParseResponse(decodedResp, nil) + if err != nil { + return err + } + + src, ok := responder.Source.(ocsp.InMemorySource) + if !ok { + return errors.New("Could not typecast responder.Source to InMemorySource") + } + + // from cfssl/ocsp/responder.go + src[r.SerialNumber.String()] = decodedResp + + //encode to base64 before saving to responses file + b64resp := base64.StdEncoding.EncodeToString(decodedResp) + + f, err := os.OpenFile(config.OCSPRespFile, os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + log.Printf("Failed to open ocsp file to record new: %s", err) + return err + } + + defer f.Close() + if _, err = f.WriteString(fmt.Sprint(b64resp, "\n")); err != nil { + log.Printf("Failed to write to ocsp file to record new: %s", err) + return err + } + return nil +}