package handlers import "crypto/ed25519" import "crypto/ecdsa" import "crypto/elliptic" import "crypto/rand" import "crypto/rsa" import "crypto/x509" import "database/sql" import "encoding/pem" import "errors" import "fmt" import "net" import "net/http" import "sort" import "strconv" import "strings" import "time" import "codit/internal/db" import "golang.org/x/crypto/ssh" import "codit/internal/middleware" import "codit/internal/models" import "codit/internal/util" type sshUserCACreateRequest struct { Name string `json:"name"` Algorithm string `json:"algorithm"` PrivateKeyPEM string `json:"private_key_pem"` Enabled bool `json:"enabled"` AllowUserSign bool `json:"allow_user_sign"` MaxUserValidSeconds int64 `json:"max_user_valid_seconds"` } type sshUserCAUpdateRequest struct { Name string `json:"name"` Enabled bool `json:"enabled"` AllowUserSign bool `json:"allow_user_sign"` MaxUserValidSeconds int64 `json:"max_user_valid_seconds"` } type sshUserCASummary struct { ID string `json:"id"` Name string `json:"name"` Algorithm string `json:"algorithm"` PublicKey string `json:"public_key"` Fingerprint string `json:"fingerprint"` SerialCounter uint64 `json:"serial_counter"` Enabled bool `json:"enabled"` AllowUserSign bool `json:"allow_user_sign"` MaxUserValidSeconds int64 `json:"max_user_valid_seconds"` CreatedAt int64 `json:"created_at"` UpdatedAt int64 `json:"updated_at"` } type sshSignUserKeyRequest struct { PublicKey string `json:"public_key"` KeyID string `json:"key_id"` Principals []string `json:"principals"` ValidSeconds int64 `json:"valid_seconds"` } type sshSignSelfUserKeyRequest struct { PublicKey string `json:"public_key"` KeyID string `json:"key_id"` GrantIDs []string `json:"grant_ids"` Principals []string `json:"principals"` ValidSeconds int64 `json:"valid_seconds"` } type sshInspectCertificateRequest struct { Certificate string `json:"certificate"` } type sshSignUserKeyResponse struct { CAID string `json:"ca_id"` Certificate string `json:"certificate"` KeyID string `json:"key_id"` Principals []string `json:"principals"` ValidAfter int64 `json:"valid_after"` ValidBefore int64 `json:"valid_before"` Serial uint64 `json:"serial"` } type sshUserCAIssuanceSummary struct { ID string `json:"id"` CAID string `json:"ca_id"` CAName string `json:"ca_name"` IssuerUserID string `json:"issuer_user_id"` IssuerUsername string `json:"issuer_username"` IssuerKind string `json:"issuer_kind"` SourcePublicKey string `json:"source_public_key"` SourcePublicKeyFingerprint string `json:"source_public_key_fingerprint"` Certificate string `json:"certificate"` KeyID string `json:"key_id"` Principals []string `json:"principals"` ValidAfter int64 `json:"valid_after"` ValidBefore int64 `json:"valid_before"` Serial uint64 `json:"serial"` RemoteAddr string `json:"remote_addr"` UserAgent string `json:"user_agent"` CreatedAt int64 `json:"created_at"` } const logIDSSHCA string = "ssh-ca" func (api *API) ListSSHUserCAs(w http.ResponseWriter, r *http.Request, _ map[string]string) { var items []models.SSHUserCA var out []sshUserCASummary var i int var err error if !api.requireAdmin(w, r) { return } items, err = api.store(r).ListSSHUserCAs() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } for i = 0; i < len(items); i++ { out = append(out, buildSSHUserCASummary(items[i])) } WriteJSON(w, http.StatusOK, out) } func (api *API) GetSSHUserCA(w http.ResponseWriter, r *http.Request, params map[string]string) { var item models.SSHUserCA var err error if !api.requireAdmin(w, r) { return } item, err = api.store(r).GetSSHUserCA(params["id"]) if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, buildSSHUserCASummary(item)) } func (api *API) CreateSSHUserCA(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req sshUserCACreateRequest var item models.SSHUserCA var summary sshUserCASummary var signer ssh.Signer var privateKeyPEM string var publicKey string var fingerprint string var algorithm string var maxUserValidSeconds int64 var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } algorithm, err = normalizeSSHCAAlgorithm(req.Algorithm) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } maxUserValidSeconds, err = normalizeSSHUserCAMaxValidSeconds(req.MaxUserValidSeconds) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if strings.TrimSpace(req.PrivateKeyPEM) == "" { privateKeyPEM, publicKey, fingerprint, err = generateSSHUserCAKeyPair(algorithm) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } } else { signer, err = parseSSHSignerFromPEM(strings.TrimSpace(req.PrivateKeyPEM)) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } privateKeyPEM = strings.TrimSpace(req.PrivateKeyPEM) publicKey = strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))) fingerprint = strings.TrimSpace(ssh.FingerprintSHA256(signer.PublicKey())) } item = models.SSHUserCA{ Name: req.Name, Algorithm: publicKeyAlgorithm(publicKey), PublicKey: publicKey, PrivateKeyPEM: privateKeyPEM, Fingerprint: fingerprint, SerialCounter: 1, Enabled: req.Enabled, AllowUserSign: req.AllowUserSign, MaxUserValidSeconds: maxUserValidSeconds, } item, err = api.store(r).CreateSSHUserCA(item) if err != nil { api.Logger.Write(logIDSSHCA, util.LOG_WARN, "create failed name=%s err=%v", req.Name, err) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } summary = buildSSHUserCASummary(item) api.Logger.Write(logIDSSHCA, util.LOG_INFO, "create success id=%s name=%s algorithm=%s", summary.ID, summary.Name, summary.Algorithm) WriteJSON(w, http.StatusCreated, summary) } func (api *API) UpdateSSHUserCA(w http.ResponseWriter, r *http.Request, params map[string]string) { var req sshUserCAUpdateRequest var item models.SSHUserCA var maxUserValidSeconds int64 var err error if !api.requireAdmin(w, r) { return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } req.Name = strings.TrimSpace(req.Name) if req.Name == "" { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"}) return } maxUserValidSeconds, err = normalizeSSHUserCAMaxValidSeconds(req.MaxUserValidSeconds) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } item, err = api.store(r).GetSSHUserCA(params["id"]) if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } item.Name = req.Name item.Enabled = req.Enabled item.AllowUserSign = req.AllowUserSign item.MaxUserValidSeconds = maxUserValidSeconds item, err = api.store(r).UpdateSSHUserCA(item) if err != nil { api.Logger.Write(logIDSSHCA, util.LOG_WARN, "update failed id=%s err=%v", params["id"], err) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } api.Logger.Write(logIDSSHCA, util.LOG_INFO, "update success id=%s name=%s enabled=%t", item.ID, item.Name, item.Enabled) WriteJSON(w, http.StatusOK, buildSSHUserCASummary(item)) } func (api *API) DeleteSSHUserCA(w http.ResponseWriter, r *http.Request, params map[string]string) { var err error if !api.requireAdmin(w, r) { return } err = api.store(r).DeleteSSHUserCA(params["id"]) if err != nil { api.Logger.Write(logIDSSHCA, util.LOG_WARN, "delete failed id=%s err=%v", params["id"], err) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } api.Logger.Write(logIDSSHCA, util.LOG_INFO, "delete success id=%s", params["id"]) WriteJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } func (api *API) DownloadSSHUserCAPublicKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var item models.SSHUserCA var err error if !api.requireAdmin(w, r) { return } item, err = api.store(r).GetSSHUserCA(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"}) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pub\"", sanitizeFilename(item.Name))) _, _ = w.Write([]byte(strings.TrimSpace(item.PublicKey) + "\n")) } func (api *API) DownloadSSHUserCAPublicKeyForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) { var item models.SSHUserCA var user models.User var ok bool var err error user, ok = middleware.UserFromContext(r.Context()) if !ok || user.Disabled { w.WriteHeader(http.StatusUnauthorized) return } item, err = api.store(r).GetSSHUserCAForUser(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"}) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.pub\"", sanitizeFilename(item.Name))) _, _ = w.Write([]byte(strings.TrimSpace(item.PublicKey) + "\n")) } func (api *API) DownloadSSHUserCAPrivateKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var item models.SSHUserCA var err error if !api.requireAdmin(w, r) { return } item, err = api.store(r).GetSSHUserCA(params["id"]) if err != nil { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"}) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.key\"", sanitizeFilename(item.Name))) _, _ = w.Write([]byte(strings.TrimSpace(item.PrivateKeyPEM) + "\n")) } func (api *API) SignSSHUserKey(w http.ResponseWriter, r *http.Request, params map[string]string) { var req sshSignUserKeyRequest var user models.User var ok bool var principals []string var validSeconds int64 var sourcePublicKey string var sourcePublicKeyFingerprint string var response sshSignUserKeyResponse var item models.SSHUserCA var err error if !api.requireAdmin(w, r) { return } user, ok = middleware.UserFromContext(r.Context()) if !ok || user.Disabled { w.WriteHeader(http.StatusUnauthorized) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } item, err = api.store(r).GetSSHUserCA(params["id"]) if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !item.Enabled { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "SSH user CA is disabled"}) return } principals = normalizeSSHPrincipals(req.Principals) if len(principals) == 0 { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one principal is required"}) return } sourcePublicKey = strings.TrimSpace(req.PublicKey) sourcePublicKeyFingerprint, err = sshAuthorizedKeyFingerprint(sourcePublicKey) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } validSeconds = req.ValidSeconds response, err = api.signSSHUserKeyWithCA(api.store(r), item, sourcePublicKey, strings.TrimSpace(req.KeyID), principals, validSeconds, 604800) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } err = api.recordSSHUserCAIssuance(r, item, user, "admin", sourcePublicKey, sourcePublicKeyFingerprint, response) if err != nil { api.Logger.Write(logIDSSHCA, util.LOG_WARN, "audit write failed ca_id=%s serial=%d err=%v", item.ID, response.Serial, err) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to record issuance audit"}) return } api.Logger.Write(logIDSSHCA, util.LOG_INFO, "sign success ca_id=%s serial=%d key_id=%s principals=%s", item.ID, response.Serial, response.KeyID, strings.Join(principals, ",")) WriteJSON(w, http.StatusOK, response) } func (api *API) ListSSHUserCAsForSelf(w http.ResponseWriter, r *http.Request, _ map[string]string) { var items []models.SSHUserCA var out []sshUserCASummary var user models.User var ok bool var i int var err error user, ok = middleware.UserFromContext(r.Context()) if !ok || user.Disabled { w.WriteHeader(http.StatusUnauthorized) return } items, err = api.store(r).ListSSHUserCAsForUser() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } for i = 0; i < len(items); i++ { out = append(out, buildSSHUserCASummary(items[i])) } WriteJSON(w, http.StatusOK, out) } func (api *API) GetSSHUserCAForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) { var item models.SSHUserCA var user models.User var ok bool var err error user, ok = middleware.UserFromContext(r.Context()) if !ok || user.Disabled { w.WriteHeader(http.StatusUnauthorized) return } item, err = api.store(r).GetSSHUserCAForUser(params["id"]) if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } WriteJSON(w, http.StatusOK, buildSSHUserCASummary(item)) } func (api *API) ListSSHUserCAIssuances(w http.ResponseWriter, r *http.Request, params map[string]string) { var ca models.SSHUserCA var limit int var items []models.SSHUserCAIssuance var out []sshUserCAIssuanceSummary var i int var err error if !api.requireAdmin(w, r) { return } ca, err = api.store(r).GetSSHUserCA(params["id"]) if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } limit = parseSSHUserCAIssuanceLimit(r.URL.Query().Get("limit")) items, err = api.store(r).ListSSHUserCAIssuancesByCA(ca.ID, limit) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } for i = 0; i < len(items); i++ { out = append(out, buildSSHUserCAIssuanceSummary(items[i], ca.Name)) } WriteJSON(w, http.StatusOK, out) } func (api *API) ListSSHUserCAIssuancesAll(w http.ResponseWriter, r *http.Request, _ map[string]string) { var limit int var caID string var items []models.SSHUserCAIssuance var cas []models.SSHUserCA var caByID map[string]string var out []sshUserCAIssuanceSummary var caName string var ok bool var i int var err error if !api.requireAdmin(w, r) { return } limit = parseSSHUserCAIssuanceLimit(r.URL.Query().Get("limit")) caID = strings.TrimSpace(r.URL.Query().Get("ca_id")) items, err = api.store(r).ListSSHUserCAIssuances(limit, caID) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } cas, err = api.store(r).ListSSHUserCAs() if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } caByID = map[string]string{} for i = 0; i < len(cas); i++ { caByID[cas[i].ID] = cas[i].Name } for i = 0; i < len(items); i++ { caName, ok = caByID[items[i].CAID] if !ok { caName = "" } out = append(out, buildSSHUserCAIssuanceSummary(items[i], caName)) } WriteJSON(w, http.StatusOK, out) } func (api *API) ListSSHUserCAIssuancesForSelf(w http.ResponseWriter, r *http.Request, _ map[string]string) { var user models.User var ok bool var limit int var items []models.SSHUserCAIssuance var out []sshUserCAIssuanceSummary var caByID map[string]string var ca models.SSHUserCA var caName string var i int var err error user, ok = middleware.UserFromContext(r.Context()) if !ok || user.Disabled { w.WriteHeader(http.StatusUnauthorized) return } limit = parseSSHUserCAIssuanceLimit(r.URL.Query().Get("limit")) items, err = api.store(r).ListSSHUserCAIssuancesForSelf(user.ID, limit) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } caByID = map[string]string{} for i = 0; i < len(items); i++ { caName, ok = caByID[items[i].CAID] if !ok { ca, err = api.store(r).GetSSHUserCA(items[i].CAID) if err == nil { caName = ca.Name } else { caName = "" } caByID[items[i].CAID] = caName } out = append(out, buildSSHUserCAIssuanceSummary(items[i], caName)) } WriteJSON(w, http.StatusOK, out) } func (api *API) InspectSSHCertificate(w http.ResponseWriter, r *http.Request, _ map[string]string) { var req sshInspectCertificateRequest var user models.User var ok bool var key ssh.PublicKey var cert *ssh.Certificate var dump string var err error user, ok = middleware.UserFromContext(r.Context()) if !ok || user.Disabled { w.WriteHeader(http.StatusUnauthorized) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } key, _, _, _, err = ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(req.Certificate))) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid SSH certificate"}) return } cert, ok = key.(*ssh.Certificate) if !ok { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "not an SSH certificate"}) return } dump = buildSSHCertificateDump(cert) WriteJSON(w, http.StatusOK, map[string]string{"dump": dump}) } func (api *API) SignSSHUserKeyForSelf(w http.ResponseWriter, r *http.Request, params map[string]string) { var req sshSignSelfUserKeyRequest var item models.SSHUserCA var user models.User var ok bool var grants []models.SSHPrincipalGrant var grantsByID map[string]models.SSHPrincipalGrant var grantsByPrincipal map[string]string var selectedGrantIDs map[string]bool var selectedIDs []string var grantIDs []string var requestedPrincipals []string var principals []string var principalsSeen map[string]bool var grant models.SSHPrincipalGrant var grantID string var principal string var maxGrantValidSeconds int64 var now int64 var i int var j int var validSeconds int64 var sourcePublicKey string var sourcePublicKeyFingerprint string var response sshSignUserKeyResponse var err error user, ok = middleware.UserFromContext(r.Context()) if !ok || user.Disabled { w.WriteHeader(http.StatusUnauthorized) return } err = DecodeJSON(r, &req) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request"}) return } item, err = api.store(r).GetSSHUserCA(params["id"]) if err != nil { if err == sql.ErrNoRows { WriteJSON(w, http.StatusNotFound, map[string]string{"error": "SSH user CA not found"}) return } WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } if !item.Enabled { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "SSH user CA is disabled"}) return } if !item.AllowUserSign { WriteJSON(w, http.StatusForbidden, map[string]string{"error": "self-sign is not allowed for this CA"}) return } now = time.Now().UTC().Unix() grants, err = api.store(r).ListActiveSSHPrincipalGrantsForUser(user.ID, now) if err != nil { WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return } sourcePublicKey = strings.TrimSpace(req.PublicKey) sourcePublicKeyFingerprint, err = sshAuthorizedKeyFingerprint(sourcePublicKey) if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } requestedPrincipals = normalizeSSHPrincipals(req.Principals) if len(grants) == 0 { principals = []string{user.Username} } else { grantsByID = map[string]models.SSHPrincipalGrant{} grantsByPrincipal = map[string]string{} selectedGrantIDs = map[string]bool{} principalsSeen = map[string]bool{} for i = 0; i < len(grants); i++ { grantsByID[grants[i].ID] = grants[i] for j = 0; j < len(grants[i].Principals); j++ { principal = grants[i].Principals[j] if _, ok = grantsByPrincipal[principal]; !ok { grantsByPrincipal[principal] = grants[i].ID } } } grantIDs = normalizeSSHGrantIDs(req.GrantIDs) if len(grantIDs) == 0 { requestedPrincipals = normalizeSSHPrincipals(req.Principals) for i = 0; i < len(requestedPrincipals); i++ { grantID, ok = grantsByPrincipal[requestedPrincipals[i]] if !ok { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "selected principal is not granted"}) return } selectedGrantIDs[grantID] = true } } else { for i = 0; i < len(grantIDs); i++ { selectedGrantIDs[grantIDs[i]] = true } } if len(selectedGrantIDs) == 0 { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "at least one granted principal is required"}) return } for grantID = range selectedGrantIDs { selectedIDs = append(selectedIDs, grantID) } sort.Strings(selectedIDs) for i = 0; i < len(selectedIDs); i++ { grant, ok = grantsByID[selectedIDs[i]] if !ok { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "selected grant is not active"}) return } for j = 0; j < len(grant.Principals); j++ { principal = grant.Principals[j] if !principalsSeen[principal] { principals = append(principals, principal) principalsSeen[principal] = true } } selectedGrantIDs[grant.ID] = true if grant.MaxCertValidSeconds > 0 && (maxGrantValidSeconds == 0 || grant.MaxCertValidSeconds < maxGrantValidSeconds) { maxGrantValidSeconds = grant.MaxCertValidSeconds } } } validSeconds = req.ValidSeconds if maxGrantValidSeconds > 0 && maxGrantValidSeconds < item.MaxUserValidSeconds { response, err = api.signSSHUserKeyWithCA(api.store(r), item, sourcePublicKey, strings.TrimSpace(req.KeyID), principals, validSeconds, maxGrantValidSeconds) } else { response, err = api.signSSHUserKeyWithCA(api.store(r), item, sourcePublicKey, strings.TrimSpace(req.KeyID), principals, validSeconds, item.MaxUserValidSeconds) } if err != nil { WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } for grantID = range selectedGrantIDs { err = api.store(r).MarkSSHPrincipalGrantUsed(grantID) if err != nil { api.Logger.Write(logIDSSHCA, util.LOG_WARN, "self-sign grant consume failed ca_id=%s user=%s grant_id=%s err=%v", item.ID, user.Username, grantID, err) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "selected principal grant is not active"}) return } } err = api.recordSSHUserCAIssuance(r, item, user, "self", sourcePublicKey, sourcePublicKeyFingerprint, response) if err != nil { api.Logger.Write(logIDSSHCA, util.LOG_WARN, "self-sign audit write failed ca_id=%s user=%s serial=%d err=%v", item.ID, user.Username, response.Serial, err) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to record issuance audit"}) return } api.Logger.Write(logIDSSHCA, util.LOG_INFO, "self-sign success ca_id=%s user=%s serial=%d key_id=%s", item.ID, user.Username, response.Serial, response.KeyID) WriteJSON(w, http.StatusOK, response) } func (api *API) signSSHUserKeyWithCA(store *db.Store, item models.SSHUserCA, publicKey string, keyID string, principals []string, validSeconds int64, maxValidSeconds int64) (sshSignUserKeyResponse, error) { var response sshSignUserKeyResponse var signer ssh.Signer var key ssh.PublicKey var serial uint64 var cert ssh.Certificate var now int64 var err error if strings.TrimSpace(publicKey) == "" { return response, errors.New("public_key is required") } signer, err = parseSSHSignerFromPEM(item.PrivateKeyPEM) if err != nil { return response, err } key, _, _, _, err = ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(publicKey))) if err != nil { return response, errors.New("invalid user public key") } if maxValidSeconds <= 0 { maxValidSeconds = 1800 } if validSeconds <= 0 { validSeconds = 1800 if validSeconds > maxValidSeconds { validSeconds = maxValidSeconds } } if validSeconds < 60 || validSeconds > maxValidSeconds { return response, fmt.Errorf("valid_seconds must be between 60 and %d", maxValidSeconds) } serial, err = store.NextSSHUserCASerial(item.ID) if err != nil { return response, err } if keyID == "" { keyID = fmt.Sprintf("codit-%s-%d", item.Name, serial) } now = time.Now().UTC().Unix() cert = ssh.Certificate{ Key: key, Serial: serial, CertType: ssh.UserCert, KeyId: keyID, ValidPrincipals: principals, ValidAfter: uint64(now - 30), ValidBefore: uint64(now + validSeconds), Permissions: ssh.Permissions{Extensions: map[string]string{ "permit-agent-forwarding": "", "permit-port-forwarding": "", "permit-pty": "", "permit-user-rc": "", "permit-X11-forwarding": "", }}, } err = cert.SignCert(rand.Reader, signer) if err != nil { return response, err } response = sshSignUserKeyResponse{ CAID: item.ID, Certificate: strings.TrimSpace(string(ssh.MarshalAuthorizedKey(&cert))), KeyID: keyID, Principals: principals, ValidAfter: now - 30, ValidBefore: now + validSeconds, Serial: serial, } return response, nil } func (api *API) recordSSHUserCAIssuance(r *http.Request, ca models.SSHUserCA, issuer models.User, issuerKind string, sourcePublicKey string, sourcePublicKeyFingerprint string, signed sshSignUserKeyResponse) error { var row models.SSHUserCAIssuance var err error row = models.SSHUserCAIssuance{ CAID: ca.ID, IssuerUserID: issuer.ID, IssuerUsername: issuer.Username, IssuerKind: strings.TrimSpace(issuerKind), SourcePublicKey: strings.TrimSpace(sourcePublicKey), SourcePublicKeyFingerprint: strings.TrimSpace(sourcePublicKeyFingerprint), Certificate: signed.Certificate, KeyID: signed.KeyID, Principals: signed.Principals, ValidAfter: signed.ValidAfter, ValidBefore: signed.ValidBefore, Serial: signed.Serial, RemoteAddr: requestRemoteAddr(r), UserAgent: strings.TrimSpace(r.UserAgent()), } _, err = api.store(r).CreateSSHUserCAIssuance(row) if err != nil { return err } return nil } func buildSSHUserCAIssuanceSummary(item models.SSHUserCAIssuance, caName string) sshUserCAIssuanceSummary { return sshUserCAIssuanceSummary{ ID: item.ID, CAID: item.CAID, CAName: caName, IssuerUserID: item.IssuerUserID, IssuerUsername: item.IssuerUsername, IssuerKind: item.IssuerKind, SourcePublicKey: item.SourcePublicKey, SourcePublicKeyFingerprint: item.SourcePublicKeyFingerprint, Certificate: item.Certificate, KeyID: item.KeyID, Principals: item.Principals, ValidAfter: item.ValidAfter, ValidBefore: item.ValidBefore, Serial: item.Serial, RemoteAddr: item.RemoteAddr, UserAgent: item.UserAgent, CreatedAt: item.CreatedAt, } } func parseSSHUserCAIssuanceLimit(raw string) int { var value int var err error value = 50 raw = strings.TrimSpace(raw) if raw == "" { return value } value, err = strconv.Atoi(raw) if err != nil { return 50 } if value <= 0 { return 50 } if value > 500 { return 500 } return value } func requestRemoteAddr(r *http.Request) string { var host string var err error host, _, err = net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) if err == nil && strings.TrimSpace(host) != "" { return strings.TrimSpace(host) } return strings.TrimSpace(r.RemoteAddr) } func buildSSHUserCASummary(item models.SSHUserCA) sshUserCASummary { return sshUserCASummary{ ID: item.ID, Name: item.Name, Algorithm: item.Algorithm, PublicKey: item.PublicKey, Fingerprint: item.Fingerprint, SerialCounter: item.SerialCounter, Enabled: item.Enabled, AllowUserSign: item.AllowUserSign, MaxUserValidSeconds: item.MaxUserValidSeconds, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, } } func sshAuthorizedKeyFingerprint(raw string) (string, error) { var key ssh.PublicKey var err error key, _, _, _, err = ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(raw))) if err != nil { return "", errors.New("invalid user public key") } return strings.TrimSpace(ssh.FingerprintSHA256(key)), nil } func buildSSHCertificateDump(cert *ssh.Certificate) string { var sb strings.Builder var criticalKeys []string var extensionKeys []string var i int var key string fmt.Fprintf(&sb, "Type: %s %s certificate\n", cert.Type(), sshCertificateTypeName(cert.CertType)) fmt.Fprintf(&sb, "Public key: %s %s\n", cert.Key.Type(), ssh.FingerprintSHA256(cert.Key)) if cert.SignatureKey != nil { fmt.Fprintf(&sb, "Signing CA: %s %s\n", cert.SignatureKey.Type(), ssh.FingerprintSHA256(cert.SignatureKey)) } fmt.Fprintf(&sb, "Key ID: \"%s\"\n", cert.KeyId) fmt.Fprintf(&sb, "Serial: %d\n", cert.Serial) fmt.Fprintf(&sb, "Valid: from %s to %s\n", formatSSHCertificateTime(cert.ValidAfter), formatSSHCertificateTime(cert.ValidBefore)) fmt.Fprintf(&sb, "Principals:\n") if len(cert.ValidPrincipals) == 0 { fmt.Fprintf(&sb, " (none)\n") } else { for i = 0; i < len(cert.ValidPrincipals); i++ { fmt.Fprintf(&sb, " %s\n", cert.ValidPrincipals[i]) } } fmt.Fprintf(&sb, "Critical Options:\n") if len(cert.Permissions.CriticalOptions) == 0 { fmt.Fprintf(&sb, " (none)\n") } else { for key = range cert.Permissions.CriticalOptions { criticalKeys = append(criticalKeys, key) } sort.Strings(criticalKeys) for i = 0; i < len(criticalKeys); i++ { key = criticalKeys[i] if cert.Permissions.CriticalOptions[key] == "" { fmt.Fprintf(&sb, " %s\n", key) } else { fmt.Fprintf(&sb, " %s %s\n", key, cert.Permissions.CriticalOptions[key]) } } } fmt.Fprintf(&sb, "Extensions:\n") if len(cert.Permissions.Extensions) == 0 { fmt.Fprintf(&sb, " (none)\n") } else { for key = range cert.Permissions.Extensions { extensionKeys = append(extensionKeys, key) } sort.Strings(extensionKeys) for i = 0; i < len(extensionKeys); i++ { key = extensionKeys[i] if cert.Permissions.Extensions[key] == "" { fmt.Fprintf(&sb, " %s\n", key) } else { fmt.Fprintf(&sb, " %s %s\n", key, cert.Permissions.Extensions[key]) } } } return sb.String() } func sshCertificateTypeName(value uint32) string { if value == ssh.UserCert { return "user" } if value == ssh.HostCert { return "host" } return "unknown" } func formatSSHCertificateTime(value uint64) string { if value == ssh.CertTimeInfinity { return "forever" } return time.Unix(int64(value), 0).UTC().Format(time.RFC3339) } func parseSSHSignerFromPEM(raw string) (ssh.Signer, error) { var privateKey any var signer ssh.Signer var err error privateKey, err = ssh.ParseRawPrivateKey([]byte(strings.TrimSpace(raw))) if err != nil { return nil, err } signer, err = ssh.NewSignerFromKey(privateKey) if err != nil { return nil, err } return signer, nil } func normalizeSSHCAAlgorithm(raw string) (string, error) { var value string value = strings.ToLower(strings.TrimSpace(raw)) if value == "" { return "ed25519", nil } if value == "ed25519" || value == "ssh-ed25519" { return "ed25519", nil } if value == "rsa-4096" || value == "rsa4096" || value == "ssh-rsa" { return "rsa-4096", nil } if value == "ecdsa-p256" || value == "ecdsa256" || value == "ecdsa-sha2-nistp256" { return "ecdsa-p256", nil } return "", errors.New("algorithm must be ed25519, rsa-4096, or ecdsa-p256") } func normalizeSSHUserCAMaxValidSeconds(raw int64) (int64, error) { var value int64 value = raw if value <= 0 { value = 1800 } if value < 60 || value > 604800 { return 0, errors.New("max_user_valid_seconds must be between 60 and 604800") } return value, nil } func generateSSHUserCAKeyPair(algorithm string) (string, string, string, error) { var privateKey any var edPrivate ed25519.PrivateKey var rsaPrivate *rsa.PrivateKey var ecdsaPrivate *ecdsa.PrivateKey var pkcs8 []byte var privatePEM string var signer ssh.Signer var err error if algorithm == "ed25519" { _, edPrivate, err = ed25519.GenerateKey(rand.Reader) if err != nil { return "", "", "", err } privateKey = edPrivate } else if algorithm == "rsa-4096" { rsaPrivate, err = rsa.GenerateKey(rand.Reader, 4096) if err != nil { return "", "", "", err } privateKey = rsaPrivate } else if algorithm == "ecdsa-p256" { ecdsaPrivate, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return "", "", "", err } privateKey = ecdsaPrivate } else { return "", "", "", errors.New("unsupported algorithm") } pkcs8, err = x509.MarshalPKCS8PrivateKey(privateKey) if err != nil { return "", "", "", err } privatePEM = strings.TrimSpace(string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}))) signer, err = ssh.NewSignerFromKey(privateKey) if err != nil { return "", "", "", err } return privatePEM, strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signer.PublicKey()))), strings.TrimSpace(ssh.FingerprintSHA256(signer.PublicKey())), nil } func publicKeyAlgorithm(publicKey string) string { var key ssh.PublicKey var err error key, _, _, _, err = ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(publicKey))) if err != nil { return "unknown" } return key.Type() } func normalizeSSHPrincipals(items []string) []string { var out []string var seen map[string]bool var i int var value string seen = map[string]bool{} for i = 0; i < len(items); i++ { value = strings.TrimSpace(items[i]) if value == "" { continue } if seen[value] { continue } seen[value] = true out = append(out, value) } return out } func normalizeSSHGrantIDs(items []string) []string { var out []string var seen map[string]bool var i int var value string seen = map[string]bool{} for i = 0; i < len(items); i++ { value = strings.TrimSpace(items[i]) if value == "" { continue } if seen[value] { continue } seen[value] = true out = append(out, value) } return out }