diff options
Diffstat (limited to 'app/controllers/ca.go')
-rw-r--r-- | app/controllers/ca.go | 172 |
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 @@ | |||
1 | package controllers | ||
2 | |||
3 | import ( | ||
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 | |||
17 | type CASecret struct { | ||
18 | Key string `mapstructure:"key"` | ||
19 | } | ||
20 | |||
21 | type CAHandlerConfig struct { | ||
22 | Logger echo.Logger | ||
23 | Users models.UserStore | ||
24 | Expiration time.Duration | ||
25 | Secret CASecret | ||
26 | } | ||
27 | |||
28 | type CAHandler struct { | ||
29 | Logger echo.Logger | ||
30 | Users models.UserStore | ||
31 | Expiration time.Duration | ||
32 | signer ssh.Signer | ||
33 | } | ||
34 | |||
35 | func 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 | |||
51 | func (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 | |||
86 | func (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 | |||
96 | func (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 | } | ||