package mockid import ( "crypto/sha1" "crypto/sha256" "crypto/sha512" "encoding/base64" "encoding/json" "fmt" "io/ioutil" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" "git.coolaj86.com/coolaj86/go-mockid/mockid/api" "git.coolaj86.com/coolaj86/go-mockid/xkeypairs" "git.rootprojects.org/root/keypairs" "git.rootprojects.org/root/keypairs/keyfetch" "github.com/google/uuid" ) // Route returns an HTTP Mux containing the full API func Route(jwksPrefix string, privkey keypairs.PrivateKey) http.Handler { Init() // TODO get from main() tokPrefix := jwksPrefix pubkey := keypairs.NewPublicKey(privkey.Public()) http.HandleFunc("/api/new-nonce", func(w http.ResponseWriter, r *http.Request) { baseURL := getBaseURL(r) /* res.statusCode = 200; res.setHeader("Cache-Control", "max-age=0, no-cache, no-store"); // TODO //res.setHeader("Date", "Sun, 10 Mar 2019 08:04:45 GMT"); // is this the expiration of the nonce itself? methinks maybe so //res.setHeader("Expires", "Sun, 10 Mar 2019 08:04:45 GMT"); // TODO use one of the registered domains //var indexURL = "https://acme-staging-v02.api.letsencrypt.org/index" */ //var port = (state.config.ipc && state.config.ipc.port || state._ipc.port || undefined); //var indexURL = "http://localhost:" + port + "/index"; indexURL := baseURL + "/index" w.Header().Set("Link", "<"+indexURL+">;rel=\"index\"") w.Header().Set("Cache-Control", "max-age=0, no-cache, no-store") w.Header().Set("Pragma", "no-cache") //res.setHeader("Strict-Transport-Security", "max-age=604800"); w.Header().Set("X-Frame-Options", "DENY") issueNonce(w, r) }) http.HandleFunc("/api/new-account", requireNonce(func(w http.ResponseWriter, r *http.Request) { // Try to decode the request body into the struct. If there is an error, // respond to the client with the error message and a 400 status code. data := map[string]string{} err := json.NewDecoder(r.Body).Decode(&data) if nil != err { http.Error(w, err.Error(), http.StatusBadRequest) return } // TODO check DNS for MX records parts := strings.Split(data["to"], ", <>\n\r\t") to := parts[0] if len(parts) > 1 || !strings.Contains(to, "@") { http.Error(w, "invalid email address", http.StatusBadRequest) return } // TODO expect JWK in JWS/JWT // TODO place validated JWK into file with token token, err := uuid.NewRandom() if nil != err { // nothing else to do if we run out of random // or are on a platform that doesn't support random panic(fmt.Errorf("random bytes read failure: %w", err)) } token64 := base64.RawURLEncoding.EncodeToString([]byte(token[:])) // hash token to prevent fs read timing attacks hash := sha1.Sum(append(token[:], salt...)) tokname := base64.RawURLEncoding.EncodeToString(hash[:]) if err := ioutil.WriteFile( filepath.Join(tokPrefix, tokname+".tok.txt"), []byte(`{"comment":"I have no idea..."}`), os.FileMode(0600), ); nil != err { http.Error(w, "database connection failed when writing verification token", http.StatusInternalServerError) return } subject := "Verify New Account" // TODO go tpl // TODO determine OS and Browser from user agent baseURL := getBaseURL(r) text := fmt.Sprintf( "It looks like you just tried to register a new Pocket ID account.\n\n Verify account: %s/verify/%s\n\nNot you? Just ignore this message.", baseURL, token64, ) _, err = SendSimpleMessage(to, defaultFrom, subject, text, defaultReplyTo) if nil != err { // TODO neuter mailgun output http.Error(w, err.Error(), http.StatusBadRequest) return } fmt.Fprintf(w, `{ "success": true, "error": "" }%s`, "\n") })) // TODO use chi http.HandleFunc("/verify/", requireNonce(func(w http.ResponseWriter, r *http.Request) { parts := strings.Split(r.URL.Path, "/") if 3 != len(parts) { http.Error(w, "invalid url path", http.StatusBadRequest) return } token64 := parts[2] token, err := base64.RawURLEncoding.DecodeString(token64) if err != nil || 0 == len(token) { http.Error(w, "invalid url path", http.StatusBadRequest) return } // hash token to prevent fs read timing attacks hash := sha1.Sum(append(token, salt...)) tokname := base64.RawURLEncoding.EncodeToString(hash[:]) tokfile := filepath.Join(tokPrefix, tokname+".tok.txt") _, err = ioutil.ReadFile(tokfile) if nil != err { http.Error(w, "database connection failed when reading verification token", http.StatusInternalServerError) return } // TODO promote JWK to public... and related to an ID or email?? os.Remove(tokfile) fmt.Fprintf(w, `{ "success": true, "error": "" }%s`, "\n") })) http.HandleFunc("/api/jwks", func(w http.ResponseWriter, r *http.Request) { log.Printf("%s %s %s", r.Method, r.Host, r.URL.Path) if "POST" != r.Method { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } tok := make(map[string]interface{}) decoder := json.NewDecoder(r.Body) err := decoder.Decode(&tok) if nil != err { http.Error(w, "Bad Request: invalid json", http.StatusBadRequest) return } defer r.Body.Close() // TODO better, JSON error messages if _, ok := tok["d"]; ok { http.Error(w, "Bad Request: private key", http.StatusBadRequest) return } kty, _ := tok["kty"].(string) switch kty { case "EC": postEC(jwksPrefix, tok, w, r) case "RSA": postRSA(jwksPrefix, tok, w, r) default: http.Error(w, "Bad Request: only EC and RSA keys are supported", http.StatusBadRequest) return } }) http.HandleFunc("/access_token", func(w http.ResponseWriter, r *http.Request) { log.Printf("%s %s\n", r.Method, r.URL.Path) _, _, token := GenToken(getBaseURL(r), privkey, r.URL.Query()) fmt.Fprintf(w, token) }) http.HandleFunc("/private.jwk.json", api.GeneratePrivateJWK) http.HandleFunc("/priv.der", api.GeneratePrivateDER) http.HandleFunc("/priv.pem", api.GeneratePrivatePEM) http.HandleFunc("/public.jwk.json", api.GeneratePublicJWK) http.HandleFunc("/pub.der", api.GeneratePublicDER) http.HandleFunc("/pub.pem", api.GeneratePublicPEM) http.HandleFunc("/inspect_token", func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") log.Printf("%s %s %s\n", r.Method, r.URL.Path, token) if "" == token { token = r.URL.Query().Get("access_token") if "" == token { http.Error(w, "Bad Format: missing Authorization header and 'access_token' query", http.StatusBadRequest) return } } else { parts := strings.Split(token, " ") if 2 != len(parts) { http.Error(w, "Bad Format: expected Authorization header to be in the format of 'Bearer '", http.StatusBadRequest) return } token = parts[1] } parts := strings.Split(token, ".") if 3 != len(parts) { http.Error(w, "Bad Format: token should be in the format of ..", http.StatusBadRequest) return } protected64 := parts[0] payload64 := parts[1] signature64 := parts[2] protectedB, err := base64.RawURLEncoding.DecodeString(protected64) if nil != err { http.Error(w, "Bad Format: token's header should be URL-safe base64 encoded", http.StatusBadRequest) return } payloadB, err := base64.RawURLEncoding.DecodeString(payload64) if nil != err { http.Error(w, "Bad Format: token's payload should be URL-safe base64 encoded", http.StatusBadRequest) return } // TODO verify signature sig, err := base64.RawURLEncoding.DecodeString(signature64) if nil != err { http.Error(w, "Bad Format: token's signature should be URL-safe base64 encoded", http.StatusBadRequest) return } errors := []string{} protected := map[string]interface{}{} err = json.Unmarshal(protectedB, &protected) if nil != err { http.Error(w, "Bad Format: token's header should be URL-safe base64-encoded JSON", http.StatusBadRequest) return } kid, kidOK := protected["kid"].(string) // TODO parse jwkM _, jwkOK := protected["jwk"] if !kidOK && !jwkOK { errors = append(errors, "must have either header.kid or header.jwk") } data := map[string]interface{}{} err = json.Unmarshal(payloadB, &data) if nil != err { http.Error(w, "Bad Format: token's payload should be URL-safe base64-encoded JSON", http.StatusBadRequest) return } iss, issOK := data["iss"].(string) if !jwkOK && !issOK { errors = append(errors, "payload.iss must exist to complement header.kid") } pub, err := keyfetch.OIDCJWK(kid, iss) if nil != err { fmt.Println("couldn't fetch pub key:") fmt.Println(err) } fmt.Println("fetched pub key:") fmt.Println(pub) hash := sha256.Sum256([]byte(fmt.Sprintf("%s.%s", protected64, payload64))) verified := JOSEVerify(pub, hash[:], sig) inspected := &InspectableToken{ Public: pub, Protected: protected, Payload: data, Signature: signature64, Verified: verified, Errors: errors, } tokenB, _ := json.MarshalIndent(inspected, "", " ") if nil != err { http.Error(w, "Bad Format: malformed token, or malformed jwk at issuer url", http.StatusInternalServerError) return } fmt.Fprintf(w, string(tokenB)) }) http.HandleFunc("/authorization_header", func(w http.ResponseWriter, r *http.Request) { log.Printf("%s %s\n", r.Method, r.URL.Path) var header string headers, _ := r.URL.Query()["header"] if 0 == len(headers) { header = "Authorization" } else { header = headers[0] } var prefix string prefixes, _ := r.URL.Query()["prefix"] if 0 == len(prefixes) { prefix = "Bearer " } else { prefix = prefixes[0] } _, _, token := GenToken(getBaseURL(r), privkey, r.URL.Query()) fmt.Fprintf(w, "%s: %s%s", header, prefix, token) }) http.HandleFunc("/key.jwk.json", func(w http.ResponseWriter, r *http.Request) { log.Printf("%s %s", r.Method, r.URL.Path) jwk := string(xkeypairs.MarshalJWKPrivateKey(privkey)) jwk = strings.Replace(jwk, `{"`, `{ "`, 1) jwk = strings.Replace(jwk, `",`, `", `, -1) jwk = jwk[0 : len(jwk)-1] jwk = jwk + `, "ext": true , "key_ops": ["sign"] }` // `{ "kty": "EC" , "crv": %q , "d": %q , "x": %q , "y": %q }`, jwk.Crv, jwk.D, jwk.X, jwk.Y fmt.Fprintf(w, jwk) }) http.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { baseURL := getBaseURL(r) log.Printf("%s %s\n", r.Method, r.URL.Path) fmt.Fprintf(w, `{ "issuer": "%s", "jwks_uri": "%s/.well-known/jwks.json" }`, baseURL, baseURL) }) http.HandleFunc("/.well-known/jwks.json", func(w http.ResponseWriter, r *http.Request) { log.Printf("%s %s %s", r.Method, r.Host, r.URL.Path) parts := strings.Split(r.Host, ".") kid := parts[0] b, err := ioutil.ReadFile(filepath.Join(jwksPrefix, strings.ToLower(kid)+".jwk.json")) if nil != err { //http.Error(w, "Not Found", http.StatusNotFound) exp := strconv.FormatInt(time.Now().Add(15*time.Minute).Unix(), 10) jwk := string(keypairs.MarshalJWKPublicKey(pubkey)) jwk = strings.Replace(jwk, `{"`, `{ "`, 1) jwk = strings.Replace(jwk, `",`, `" ,`, -1) jwk = jwk[0 : len(jwk)-1] jwk = jwk + fmt.Sprintf(`, "ext": true , "key_ops": ["verify"], "exp": %s }`, exp) // { "kty": "EC" , "crv": %q , "x": %q , "y": %q , "kid": %q , "ext": true , "key_ops": ["verify"] , "exp": %s } jwkstr := fmt.Sprintf(`{ "keys": [ %s ] }`, jwk) fmt.Println(jwkstr) fmt.Fprintf(w, jwkstr) return } tok := &PublicJWK{} err = json.Unmarshal(b, tok) if nil != err { // TODO delete the bad file? http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } jwkstr := fmt.Sprintf( `{ "keys": [ { "kty": "EC", "crv": %q, "x": %q, "y": %q, "kid": %q,`+ ` "ext": true, "key_ops": ["verify"], "exp": %s } ] }`, tok.Crv, tok.X, tok.Y, tok.KeyID, strconv.FormatInt(time.Now().Add(15*time.Minute).Unix(), 10), ) fmt.Println(jwkstr) fmt.Fprintf(w, jwkstr) }) return http.DefaultServeMux } func getBaseURL(r *http.Request) string { var scheme string if nil != r.TLS || "https" == r.Header.Get("X-Forwarded-Proto") { scheme = "https:" } else { scheme = "http:" } return fmt.Sprintf( "%s//%s", scheme, r.Host, ) } // HTTPError describes an error that should be propagated to the HTTP client type HTTPError struct { message string code int } func postEC(jwksPrefix string, tok map[string]interface{}, w http.ResponseWriter, r *http.Request) { data, err := postECHelper(r.Host, jwksPrefix, tok, r) if nil != err { http.Error(w, err.message, err.code) return } w.Write(data) } func postECHelper(hostname, jwksPrefix string, tok map[string]interface{}, r *http.Request) ([]byte, *HTTPError) { crv, ok := tok["crv"].(string) if 5 != len(crv) || "P-" != crv[:2] { return nil, &HTTPError{"Bad Request: bad curve", http.StatusBadRequest} } x, ok := tok["x"].(string) if !ok { return nil, &HTTPError{"Bad Request: missing 'x'", http.StatusBadRequest} } y, ok := tok["y"].(string) if !ok { return nil, &HTTPError{"Bad Request: missing 'y'", http.StatusBadRequest} } thumbprintable := []byte( fmt.Sprintf(`{"crv":%q,"kty":"EC","x":%q,"y":%q}`, crv, x, y), ) alg := crv[2:] var thumb []byte switch alg { case "256": hash := sha256.Sum256(thumbprintable) thumb = hash[:] case "384": hash := sha512.Sum384(thumbprintable) thumb = hash[:] case "521": fallthrough case "512": hash := sha512.Sum512(thumbprintable) thumb = hash[:] default: return nil, &HTTPError{"Bad Request: bad key length or curve", http.StatusBadRequest} } kid := base64.RawURLEncoding.EncodeToString(thumb) if kid2, _ := tok["kid"].(string); "" != kid2 && kid != kid2 { return nil, &HTTPError{"Bad Request: kid should be " + kid, http.StatusBadRequest} } pub := []byte(fmt.Sprintf( `{"crv":%q,"kid":%q,"kty":"EC","x":%q,"y":%q}`, crv, kid, x, y, )) // TODO allow posting at the top-level? // TODO support a group of keys by PPID // (right now it's only by KID) if !strings.HasPrefix(hostname, strings.ToLower(kid)+".") { return nil, &HTTPError{"Bad Request: prefix should be " + kid, http.StatusBadRequest} } if err := ioutil.WriteFile( filepath.Join(jwksPrefix, strings.ToLower(kid)+".jwk.json"), pub, 0644, ); nil != err { fmt.Println("can't write file") return nil, &HTTPError{"Internal Server Error", http.StatusInternalServerError} } baseURL := getBaseURL(r) return []byte(fmt.Sprintf( `{ "iss":%q, "jwks_url":%q }`, baseURL+"/", baseURL+"/.well-known/jwks.json", )), nil } func postRSA(jwksPrefix string, tok map[string]interface{}, w http.ResponseWriter, r *http.Request) { data, err := postRSAHelper(r.Host, jwksPrefix, tok, r) if nil != err { http.Error(w, err.message, err.code) return } w.Write(data) } func postRSAHelper(hostname, jwksPrefix string, tok map[string]interface{}, r *http.Request) ([]byte, *HTTPError) { e, ok := tok["e"].(string) if !ok { return nil, &HTTPError{"Bad Request: missing 'e'", http.StatusBadRequest} } n, ok := tok["n"].(string) if !ok { return nil, &HTTPError{"Bad Request: missing 'n'", http.StatusBadRequest} } thumbprintable := []byte( fmt.Sprintf(`{"e":%q,"kty":"RSA","n":%q}`, e, n), ) var thumb []byte // TODO handle bit lengths well switch 3 * (len(n) / 4.0) { case 256: hash := sha256.Sum256(thumbprintable) thumb = hash[:] case 384: hash := sha512.Sum384(thumbprintable) thumb = hash[:] case 512: hash := sha512.Sum512(thumbprintable) thumb = hash[:] default: return nil, &HTTPError{"Bad Request: only standard RSA key lengths (2048, 3072, 4096) are supported", http.StatusBadRequest} } kid := base64.RawURLEncoding.EncodeToString(thumb) if kid2, _ := tok["kid"].(string); "" != kid2 && kid != kid2 { return nil, &HTTPError{"Bad Request: kid should be " + kid, http.StatusBadRequest} } pub := []byte(fmt.Sprintf( `{"e":%q,"kid":%q,"kty":"EC","n":%q}`, e, kid, n, )) // TODO allow posting at the top-level? // TODO support a group of keys by PPID // (right now it's only by KID) if !strings.HasPrefix(hostname, strings.ToLower(kid)+".") { return nil, &HTTPError{"Bad Request: prefix should be " + kid, http.StatusBadRequest} } if err := ioutil.WriteFile( filepath.Join(jwksPrefix, strings.ToLower(kid)+".jwk.json"), pub, 0644, ); nil != err { fmt.Println("can't write file") return nil, &HTTPError{"Internal Server Error", http.StatusInternalServerError} } baseURL := getBaseURL(r) return []byte(fmt.Sprintf( `{ "iss":%q, "jwks_url":%q }`, baseURL+"/", baseURL+"/.well-known/jwks.json", )), nil }