summaryrefslogtreecommitdiff
path: root/app/controllers/ca.go
diff options
context:
space:
mode:
Diffstat (limited to 'app/controllers/ca.go')
-rw-r--r--app/controllers/ca.go172
1 files changed, 172 insertions, 0 deletions
diff --git a/app/controllers/ca.go b/app/controllers/ca.go
new file mode 100644
index 0000000..632db50
--- /dev/null
+++ b/app/controllers/ca.go
@@ -0,0 +1,172 @@
1package controllers
2
3import (
4 "crypto/rand"
5 "fmt"
6 "io"
7 "net/http"
8 "strings"
9 "time"
10
11 "code.crute.us/mcrute/ssh-proxy/app/middleware"
12 "code.crute.us/mcrute/ssh-proxy/app/models"
13 "github.com/labstack/echo/v4"
14 "golang.org/x/crypto/ssh"
15)
16
17type CASecret struct {
18 Key string `mapstructure:"key"`
19}
20
21type CAHandlerConfig struct {
22 Logger echo.Logger
23 Users models.UserStore
24 Expiration time.Duration
25 Secret CASecret
26}
27
28type CAHandler struct {
29 Logger echo.Logger
30 Users models.UserStore
31 Expiration time.Duration
32 signer ssh.Signer
33}
34
35func NewCAHandler(cfg CAHandlerConfig) (*CAHandler, error) {
36 signer, err := ssh.ParsePrivateKey([]byte(cfg.Secret.Key))
37 if err != nil {
38 return nil, err
39 }
40
41 cfg.Logger.Infof("CA Authorized Key: %s", ssh.MarshalAuthorizedKey(signer.PublicKey()))
42
43 return &CAHandler{
44 Logger: cfg.Logger,
45 Users: cfg.Users,
46 Expiration: cfg.Expiration,
47 signer: signer,
48 }, nil
49}
50
51func (h *CAHandler) authorizeRequest(c echo.Context, certRequest *ssh.Certificate) error {
52 session := middleware.GetAuthorizedSession(c)
53
54 user, err := h.Users.Get(c.Request().Context(), session.UserId)
55 if err != nil {
56 return err
57 }
58
59 if user.Username != certRequest.ValidPrincipals[0] {
60 return fmt.Errorf("Authenticated username and cert username must match")
61 }
62
63 if !session.HasScope("ca:issue") {
64 return fmt.Errorf("Authorized session does not have scope ca:issue")
65 }
66
67 if certRequest.Extensions == nil {
68 return fmt.Errorf("Cert request extensions are empty")
69 }
70
71 hostLine, ok := certRequest.Extensions["allowed-hosts"]
72 if !ok {
73 return fmt.Errorf("Cert request allowed-hosts is blank")
74 }
75
76 for _, host := range strings.Split(hostLine, ",") {
77 if !user.AuthorizedForHost(host) {
78 return fmt.Errorf("User %s is not authorized for host %s", session.UserId, host)
79 }
80 }
81
82 h.Logger.Infof("Allowing user %s to obtain SSH certificate for hosts %s", user.Username, hostLine)
83 return nil
84}
85
86func (h *CAHandler) verifyRequestSignature(c *ssh.Certificate) error {
87 // Copied from ssh.Certificate#bytesForSigning
88 // https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.11.0:ssh/certs.go;l=499-505
89 c2 := *c
90 c2.Signature = nil
91 out := c2.Marshal()
92 // Drop trailing signature length.
93 return c.Verify(out[:len(out)-4], c.Signature)
94}
95
96func (h *CAHandler) HandleIssue(c echo.Context) error {
97 req, err := io.ReadAll(c.Request().Body)
98 if err != nil {
99 return c.JSON(http.StatusBadRequest, map[string]string{
100 "error": "Unable to read request body",
101 })
102 }
103
104 pubkey, _, _, _, err := ssh.ParseAuthorizedKey(req)
105 if err != nil {
106 return c.JSON(http.StatusBadRequest, map[string]string{
107 "error": "Error parsing certificate request",
108 })
109 }
110
111 certRequest, ok := pubkey.(*ssh.Certificate)
112 if !ok {
113 return c.JSON(http.StatusBadRequest, map[string]string{
114 "error": "Invalid format for certificate request",
115 })
116 }
117
118 if certRequest.CertType != ssh.UserCert {
119 return c.JSON(http.StatusBadRequest, map[string]string{
120 "error": "This CA only issues user certificates",
121 })
122 }
123
124 if len(certRequest.ValidPrincipals) != 1 {
125 return c.JSON(http.StatusBadRequest, map[string]string{
126 "error": "Invalid number of principals specified",
127 })
128 }
129
130 // Kinda silly I guess but at least proves that the requestor
131 // is in posession of the private key that we're signing
132 if err := h.verifyRequestSignature(certRequest); err != nil {
133 h.Logger.Error(err)
134 return c.JSON(http.StatusUnauthorized, map[string]string{
135 "error": "Invalid signature",
136 })
137 }
138
139 if err := h.authorizeRequest(c, certRequest); err != nil {
140 h.Logger.Error(err)
141 return c.JSON(http.StatusUnauthorized, map[string]string{
142 "error": "Not authorized",
143 })
144 }
145
146 utcNow := time.Now().UTC()
147
148 // Serial doesn't really matter since these are so short lived and we
149 // won't be revoking them
150 certToIssue := &ssh.Certificate{
151 Key: certRequest.Key,
152 Serial: uint64(utcNow.Unix()),
153 CertType: ssh.UserCert,
154 KeyId: fmt.Sprintf("%s_%d", certRequest.ValidPrincipals[0], utcNow.Unix()),
155 ValidPrincipals: certRequest.ValidPrincipals,
156 ValidAfter: uint64(utcNow.Add(-5 * time.Minute).Unix()),
157 ValidBefore: uint64(utcNow.Add(h.Expiration).Unix()),
158 Permissions: ssh.Permissions{
159 Extensions: map[string]string{
160 "permit-pty": "",
161 },
162 },
163 }
164
165 if err := certToIssue.SignCert(rand.Reader, h.signer); err != nil {
166 return c.JSON(http.StatusBadRequest, map[string]string{
167 "error": "Error signing certificate",
168 })
169 }
170
171 return c.Blob(http.StatusOK, "application/x-ssh-certificate", ssh.MarshalAuthorizedKey(certToIssue))
172}