Files
codit/backend/internal/handlers/ssh_user_ca.go

1125 lines
36 KiB
Go

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
}