Skip to content

Commit e2eb710

Browse files
niemp100Niklas EmptNiklas Empt
authored
TLS Certificate Enpoints (#241)
Co-authored-by: Niklas Empt <[email protected]> Co-authored-by: Niklas Empt <[email protected]>
1 parent 737644e commit e2eb710

File tree

5 files changed

+179
-36
lines changed

5 files changed

+179
-36
lines changed

docs/api/_index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ Replace the TLS certificate and key on the fly. Reload is optional.
199199

200200
The certificates need to be base64 encoded as in the following example.
201201

202+
When updating the TLS certificate with a new certificate obtained by requesting a Certificate Signing Request (CSR) from the CSR Endpoint, the keydata field must be omitted. The certificate will only be updated if the public key associated with the new certificate matches the public key generated by the CSR Endpoint.
203+
202204
Example:
203205

204206
```bash
@@ -229,6 +231,7 @@ https://127.0.0.1:8443/api/v1/admin/csr
229231
```
230232

231233
Returns PEM encoded csr
234+
When NewKey:true is specified, a new private key will be generated and stored in a temporary file. This temporary file will be retained until you upload the signed certificate to the /api/v1/admin/certs/replace endpoint, at which point the new private key will be saved in the keyfile specified in the configuration
232235

233236
### /api/v1/admin/updates/install
234237

pkg/snclient/listen_web_admin.go

Lines changed: 83 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ type csrRequestJSON struct {
5454
KeyLength int `json:"KeyLength"`
5555
}
5656

57+
type replaceCertData struct {
58+
CertData string `json:"CertData"`
59+
KeyData string `json:"KeyData"`
60+
Reload bool `json:"Reload"`
61+
}
62+
5763
// ensure we fully implement the RequestHandlerHTTP type
5864
var _ RequestHandlerHTTP = &HandlerAdmin{}
5965

@@ -148,11 +154,9 @@ func (l *HandlerWebAdmin) serveCertsCSR(res http.ResponseWriter, req *http.Reque
148154
if !l.requirePostMethod(res, req) {
149155
return
150156
}
151-
152157
// extract json payload
153158
decoder := json.NewDecoder(req.Body)
154159
decoder.DisallowUnknownFields()
155-
156160
data := csrRequestJSON{}
157161
err := decoder.Decode(&data)
158162
if err != nil {
@@ -165,7 +169,6 @@ func (l *HandlerWebAdmin) serveCertsCSR(res http.ResponseWriter, req *http.Reque
165169

166170
return
167171
}
168-
169172
if data.HostName == "" {
170173
res.Header().Set("Content-Type", "application/json")
171174
res.WriteHeader(http.StatusBadRequest)
@@ -184,7 +187,12 @@ func (l *HandlerWebAdmin) serveCertsCSR(res http.ResponseWriter, req *http.Reque
184187
}
185188
privateKey, err = rsa.GenerateKey(rand.Reader, data.KeyLength)
186189
} else {
187-
privateKey, err = l.readPrivateKey()
190+
defaultSection := l.Handler.snc.config.Section("/settings/default")
191+
keyFile, ok := defaultSection.GetString("certificate key")
192+
if !ok {
193+
l.sendError(res, fmt.Errorf("could not read certificate location from config"))
194+
}
195+
privateKey, err = l.readPrivateKey(keyFile)
188196
}
189197
if err != nil {
190198
l.sendError(res, err)
@@ -203,8 +211,10 @@ func (l *HandlerWebAdmin) serveCertsCSR(res http.ResponseWriter, req *http.Reque
203211
defSection := l.Handler.snc.config.Section("/settings/default")
204212

205213
keyFile, _ := defSection.GetString("certificate key")
214+
keyFileTemp := keyFile + ".tmp"
215+
206216
privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey)
207-
if err = os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privateKeyBytes}), 0o600); err != nil {
217+
if err = os.WriteFile(keyFileTemp, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privateKeyBytes}), 0o600); err != nil {
208218
l.sendError(res, fmt.Errorf("failed to write certificate key file %s: %s", keyFile, err.Error()))
209219

210220
return
@@ -247,13 +257,8 @@ func (l *HandlerWebAdmin) createCSR(data *csrRequestJSON, privateKey *rsa.Privat
247257
return csrPEM, nil
248258
}
249259

250-
func (l *HandlerWebAdmin) readPrivateKey() (*rsa.PrivateKey, error) {
260+
func (l *HandlerWebAdmin) readPrivateKey(keyFile string) (*rsa.PrivateKey, error) {
251261
// read private key
252-
defSection := l.Handler.snc.config.Section("/settings/default")
253-
keyFile, ok := defSection.GetString("certificate key")
254-
if !ok {
255-
return nil, fmt.Errorf("could not read certificate location from config")
256-
}
257262
pemData, err := os.ReadFile(keyFile)
258263
if err != nil {
259264
return nil, fmt.Errorf("could not read file: %s", err.Error())
@@ -288,12 +293,7 @@ func (l *HandlerWebAdmin) serveCertsReplace(res http.ResponseWriter, req *http.R
288293
// extract json payload
289294
decoder := json.NewDecoder(req.Body)
290295
decoder.DisallowUnknownFields()
291-
type postData struct {
292-
CertData string `json:"CertData"`
293-
KeyData string `json:"KeyData"`
294-
Reload bool `json:"Reload"`
295-
}
296-
data := postData{}
296+
data := replaceCertData{}
297297
err := decoder.Decode(&data)
298298
if err != nil {
299299
res.Header().Set("Content-Type", "application/json")
@@ -306,29 +306,31 @@ func (l *HandlerWebAdmin) serveCertsReplace(res http.ResponseWriter, req *http.R
306306
return
307307
}
308308

309-
certBytes := []byte{}
310-
keyBytes := []byte{}
311-
if data.CertData != "" {
312-
certBytes, err = base64.StdEncoding.DecodeString(data.CertData)
313-
if err != nil {
314-
l.sendError(res, fmt.Errorf("failed to base64 decode certdata: %s", err.Error()))
315-
316-
return
317-
}
318-
}
319-
320-
if data.KeyData != "" {
321-
keyBytes, err = base64.StdEncoding.DecodeString(data.KeyData)
322-
if err != nil {
323-
l.sendError(res, fmt.Errorf("failed to base64 decode keydata: %s", err.Error()))
324-
325-
return
326-
}
309+
certBytes, keyBytes, err := l.getBytesFromRaplacementStructData(res, data)
310+
if err != nil {
311+
return
327312
}
328313

329314
defSection := l.Handler.snc.config.Section("/settings/default")
330315
certFile, _ := defSection.GetString("certificate")
331316
keyFile, _ := defSection.GetString("certificate key")
317+
keyFileBak := keyFile + ".tmp"
318+
if data.KeyData == "" && data.CertData != "" {
319+
pubKey, certPublicKey := l.getRelevantPublicKeys(res, keyFileBak, certBytes)
320+
newPrivateKey, err := l.readPrivateKey(keyFileBak)
321+
if err != nil {
322+
l.sendError(res, err)
323+
}
324+
if pubKey.Equal(certPublicKey) {
325+
privateKeyBytes := x509.MarshalPKCS1PrivateKey(newPrivateKey)
326+
if err = os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privateKeyBytes}), 0o600); err != nil {
327+
l.sendError(res, fmt.Errorf("failed to write certificate key file %s: %s", keyFile, err.Error()))
328+
329+
return
330+
}
331+
os.Remove(keyFileBak)
332+
}
333+
}
332334

333335
if data.CertData != "" {
334336
if err := os.WriteFile(certFile, certBytes, 0o600); err != nil {
@@ -357,6 +359,52 @@ func (l *HandlerWebAdmin) serveCertsReplace(res http.ResponseWriter, req *http.R
357359
}
358360
}
359361

362+
func (l *HandlerWebAdmin) getBytesFromRaplacementStructData(res http.ResponseWriter, data replaceCertData) (certBytes, keyBytes []byte, err error) {
363+
if data.CertData != "" {
364+
certBytes, err = base64.StdEncoding.DecodeString(data.CertData)
365+
if err != nil {
366+
l.sendError(res, fmt.Errorf("failed to base64 decode certdata: %s", err.Error()))
367+
368+
return
369+
}
370+
}
371+
372+
if data.KeyData != "" {
373+
keyBytes, err = base64.StdEncoding.DecodeString(data.KeyData)
374+
if err != nil {
375+
l.sendError(res, fmt.Errorf("failed to base64 decode keydata: %s", err.Error()))
376+
377+
return
378+
}
379+
}
380+
381+
return
382+
}
383+
384+
func (l *HandlerWebAdmin) getRelevantPublicKeys(res http.ResponseWriter, tempKeyFile string, certBytes []byte) (privateKeyPubclicPart, certPublicKey *rsa.PublicKey) {
385+
newPrivateKey, err := l.readPrivateKey(tempKeyFile)
386+
387+
if err != nil {
388+
l.sendError(res, err)
389+
}
390+
newPubKey := newPrivateKey.Public()
391+
rsaNewPublicKey, ok := newPubKey.(*rsa.PublicKey)
392+
if !ok {
393+
l.sendError(res, fmt.Errorf("rsa public key in wrong format"))
394+
}
395+
block, _ := pem.Decode(certBytes)
396+
newCert, err := x509.ParseCertificate(block.Bytes)
397+
if err != nil {
398+
l.sendError(res, err)
399+
}
400+
newCertPublicKey, ok := newCert.PublicKey.(*rsa.PublicKey)
401+
if !ok {
402+
l.sendError(res, fmt.Errorf("rsa public key from csr in wrong format"))
403+
}
404+
405+
return rsaNewPublicKey, newCertPublicKey
406+
}
407+
360408
func (l *HandlerWebAdmin) serveUpdate(res http.ResponseWriter, req *http.Request) {
361409
if !l.requirePostMethod(res, req) {
362410
return

t/02_daemon_linux_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package main
22

33
import (
4+
"encoding/base64"
5+
"encoding/json"
46
"fmt"
57
"os"
68
"testing"
79

10+
"github.com/stretchr/testify/assert"
811
"github.com/stretchr/testify/require"
912
)
1013

@@ -30,3 +33,82 @@ func TestDaemonRequestsLinux(t *testing.T) {
3033
os.Remove("test.crt")
3134
os.Remove("test.key")
3235
}
36+
37+
func TestErrorBetweenSavingAndSigning(t *testing.T) {
38+
_, baseURL, _, cleanUp := daemonInit(t, "")
39+
defer os.Remove("test.crt")
40+
defer os.Remove("test.key")
41+
defer os.Remove("test.csr")
42+
43+
postData, err := json.Marshal(map[string]any{
44+
"Country": "DE",
45+
"State": "Bavaria",
46+
"Locality": "Earth",
47+
"Organization": "snclient",
48+
"OrganizationalUnit": "IT",
49+
"HostName": "Root CA SNClient",
50+
"NewKey": true,
51+
"KeyLength": 1024,
52+
})
53+
require.NoErrorf(t, err, "post data json encoded")
54+
55+
// Create Temp Server Certs
56+
runCmd(t, &cmd{
57+
Cmd: "make",
58+
Args: []string{"testca"},
59+
ErrLike: []string{"Certificate request self-signature ok"},
60+
})
61+
defer runCmd(t, &cmd{
62+
Cmd: "make",
63+
Args: []string{"clean-testca"},
64+
Like: []string{"dist"},
65+
})
66+
67+
commandResult := runCmd(t, &cmd{
68+
Cmd: "curl",
69+
Args: []string{"-s", "-u", "user:" + localDaemonAdminPassword, "-k", "-s", "-d", string(postData), baseURL + "/api/v1/admin/csr"},
70+
Dir: ".",
71+
Like: []string{"CERTIFICATE REQUEST"},
72+
})
73+
err = os.WriteFile("test.csr", []byte(commandResult.Stdout), 0o600)
74+
if err != nil {
75+
t.Fatalf("could not save certificate signing requests")
76+
}
77+
78+
runCmd(t, &cmd{
79+
Cmd: "openssl",
80+
Args: []string{"x509", "-req", "-in=test.csr", "-CA=dist/cacert.pem", "-CAkey=dist/ca.key", "-out=server.crt", "-days=365"},
81+
ErrLike: []string{"Certificate request self-signature ok"},
82+
})
83+
defer os.Remove("server.crt")
84+
85+
keyBak, _ := os.ReadFile("test.key.tmp")
86+
newCert, _ := os.ReadFile("server.crt")
87+
88+
// restart client
89+
cleanUp()
90+
_, baseURL, _, cleanUp = daemonInit(t, "")
91+
defer cleanUp()
92+
93+
postData, err = json.Marshal(map[string]interface{}{
94+
"Reload": true,
95+
"CertData": base64.StdEncoding.EncodeToString(newCert),
96+
"KeyData": "",
97+
})
98+
require.NoErrorf(t, err, "post data json encoded")
99+
100+
runCmd(t, &cmd{
101+
Cmd: "curl",
102+
Args: []string{"-s", "-u", "user:" + localDaemonAdminPassword, "-k", "-s", "-d", string(postData), baseURL + "/api/v1/admin/certs/replace"},
103+
Like: []string{`{"success":true}`},
104+
})
105+
106+
// Check if new private Key matches the on we got from the csr Endpoint
107+
key, _ := os.ReadFile("test.key")
108+
assert.Equalf(t, string(keyBak), string(key), "private keys do not match")
109+
110+
_, err = os.ReadFile("test.key.tmp")
111+
if err == nil {
112+
t.Fatalf("tempory key file was not removed")
113+
}
114+
}

t/02_daemon_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ func TestDaemonAdminCSR(t *testing.T) {
215215
_, baseURL, _, cleanUp := daemonInit(t, "")
216216
defer cleanUp()
217217

218-
postData, err := json.Marshal(map[string]interface{}{
218+
postData, err := json.Marshal(map[string]any{
219219
"Country": "DE",
220220
"State": "Bavaria",
221221
"Locality": "Earth",

t/Makefile

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,13 @@ clean:
2323
fi
2424

2525

26+
testca:
27+
mkdir -p ./dist
28+
openssl genrsa -out dist/ca.key 4096
29+
openssl req -key dist/ca.key -new -x509 -days 20000 -sha256 -extensions v3_ca -out dist/cacert.pem -subj "/C=DE/ST=Bavaria/L=Earth/O=snclient/OU=IT/CN=Root CA SNClient"
30+
openssl req -newkey rsa:2048 -nodes -keyout dist/server.key -out dist/server.csr -subj "/CN=snclient" -reqexts SAN -extensions SAN -config <(echo -e "[req]\ndistinguished_name=req\n[SAN]\nsubjectAltName=DNS:snclient")
31+
openssl x509 -req -CAcreateserial -CA dist/cacert.pem -CAkey dist/ca.key -days 20000 -in dist/server.csr -out dist/server.crt
32+
rm -f dist/server.csr dist/cacert.srl
33+
34+
clean-testca:
35+
rm -rf dist

0 commit comments

Comments
 (0)