summaryrefslogtreecommitdiff
path: root/modules/caddytls/distributedstek/distributedstek.go
blob: cef3733f2ac56e7b598c28b600b15bd2123ada93 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
// Copyright 2015 Matthew Holt and The Caddy Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package distributedstek provides TLS session ticket ephemeral
// keys (STEKs) in a distributed fashion by utilizing configured
// storage for locking and key sharing. This allows a cluster of
// machines to optimally resume TLS sessions in a load-balanced
// environment without any hassle. This is similar to what
// Twitter does, but without needing to rely on SSH, as it is
// built into the web server this way:
// https://blog.twitter.com/engineering/en_us/a/2013/forward-secrecy-at-twitter.html
package distributedstek

import (
	"bytes"
	"encoding/gob"
	"encoding/json"
	"fmt"
	"log"
	"time"

	"github.com/caddyserver/caddy/v2"
	"github.com/caddyserver/caddy/v2/modules/caddytls"
	"github.com/mholt/certmagic"
)

func init() {
	caddy.RegisterModule(Provider{})
}

// Provider implements a distributed STEK provider. This
// module will obtain STEKs from a storage module instead
// of generating STEKs internally. This allows STEKs to be
// coordinated, improving TLS session resumption in a cluster.
type Provider struct {
	// The storage module wherein to store and obtain session
	// ticket keys. If unset, Caddy's default/global-configured
	// storage module will be used.
	Storage json.RawMessage `json:"storage,omitempty" caddy:"namespace=caddy.storage inline_key=module"`

	storage    certmagic.Storage
	stekConfig *caddytls.SessionTicketService
	timer      *time.Timer
}

// CaddyModule returns the Caddy module information.
func (Provider) CaddyModule() caddy.ModuleInfo {
	return caddy.ModuleInfo{
		ID:  "tls.stek.distributed",
		New: func() caddy.Module { return new(Provider) },
	}
}

// Provision provisions s.
func (s *Provider) Provision(ctx caddy.Context) error {
	// unpack the storage module to use, if different from the default
	if s.Storage != nil {
		val, err := ctx.LoadModule(s, "Storage")
		if err != nil {
			return fmt.Errorf("loading TLS storage module: %s", err)
		}
		cmStorage, err := val.(caddy.StorageConverter).CertMagicStorage()
		if err != nil {
			return fmt.Errorf("creating TLS storage configuration: %v", err)
		}
		s.storage = cmStorage
	}

	// otherwise, use default storage
	if s.storage == nil {
		s.storage = ctx.Storage()
	}

	return nil
}

// Initialize sets the configuration for s and returns the starting keys.
func (s *Provider) Initialize(config *caddytls.SessionTicketService) ([][32]byte, error) {
	// keep a reference to the config; we'll need it when rotating keys
	s.stekConfig = config

	dstek, err := s.getSTEK()
	if err != nil {
		return nil, err
	}

	// create timer for the remaining time on the interval;
	// this timer is cleaned up only when rotate() returns
	s.timer = time.NewTimer(time.Until(dstek.NextRotation))

	return dstek.Keys, nil
}

// Next returns a channel which transmits the latest session ticket keys.
func (s *Provider) Next(doneChan <-chan struct{}) <-chan [][32]byte {
	keysChan := make(chan [][32]byte)
	go s.rotate(doneChan, keysChan)
	return keysChan
}

func (s *Provider) loadSTEK() (distributedSTEK, error) {
	var sg distributedSTEK
	gobBytes, err := s.storage.Load(stekFileName)
	if err != nil {
		return sg, err // don't wrap, in case error is certmagic.ErrNotExist
	}
	dec := gob.NewDecoder(bytes.NewReader(gobBytes))
	err = dec.Decode(&sg)
	if err != nil {
		return sg, fmt.Errorf("STEK gob corrupted: %v", err)
	}
	return sg, nil
}

func (s *Provider) storeSTEK(dstek distributedSTEK) error {
	var buf bytes.Buffer
	err := gob.NewEncoder(&buf).Encode(dstek)
	if err != nil {
		return fmt.Errorf("encoding STEK gob: %v", err)
	}
	err = s.storage.Store(stekFileName, buf.Bytes())
	if err != nil {
		return fmt.Errorf("storing STEK gob: %v", err)
	}
	return nil
}

// getSTEK locks and loads the current STEK from storage. If none
// currently exists, a new STEK is created and persisted. If the
// current STEK is outdated (NextRotation time is in the past),
// then it is rotated and persisted. The resulting STEK is returned.
func (s *Provider) getSTEK() (distributedSTEK, error) {
	s.storage.Lock(stekLockName)
	defer s.storage.Unlock(stekLockName)

	// load the current STEKs from storage
	dstek, err := s.loadSTEK()
	if _, isNotExist := err.(certmagic.ErrNotExist); isNotExist {
		// if there is none, then make some right away
		dstek, err = s.rotateKeys(dstek)
		if err != nil {
			return dstek, fmt.Errorf("creating new STEK: %v", err)
		}
	} else if err != nil {
		// some other error, that's a problem
		return dstek, fmt.Errorf("loading STEK: %v", err)
	} else if time.Now().After(dstek.NextRotation) {
		// if current STEKs are outdated, rotate them
		dstek, err = s.rotateKeys(dstek)
		if err != nil {
			return dstek, fmt.Errorf("rotating keys: %v", err)
		}
	}

	return dstek, nil
}

// rotateKeys rotates the keys of oldSTEK and returns the new distributedSTEK
// with updated keys and timestamps. It stores the returned STEK in storage,
// so this function must only be called in a storage-provided lock.
func (s *Provider) rotateKeys(oldSTEK distributedSTEK) (distributedSTEK, error) {
	var newSTEK distributedSTEK
	var err error

	newSTEK.Keys, err = s.stekConfig.RotateSTEKs(oldSTEK.Keys)
	if err != nil {
		return newSTEK, err
	}

	now := time.Now()
	newSTEK.LastRotation = now
	newSTEK.NextRotation = now.Add(time.Duration(s.stekConfig.RotationInterval))

	err = s.storeSTEK(newSTEK)
	if err != nil {
		return newSTEK, err
	}

	return newSTEK, nil
}

// rotate rotates keys on a regular basis, sending each updated set of
// keys down keysChan, until doneChan is closed.
func (s *Provider) rotate(doneChan <-chan struct{}, keysChan chan<- [][32]byte) {
	for {
		select {
		case <-s.timer.C:
			dstek, err := s.getSTEK()
			if err != nil {
				// TODO: improve this handling
				log.Printf("[ERROR] Loading STEK: %v", err)
				continue
			}

			// send the updated keys to the service
			keysChan <- dstek.Keys

			// timer channel is already drained, so reset directly (see godoc)
			s.timer.Reset(time.Until(dstek.NextRotation))

		case <-doneChan:
			// again, see godocs for why timer is stopped this way
			if !s.timer.Stop() {
				<-s.timer.C
			}
			return
		}
	}
}

type distributedSTEK struct {
	Keys                       [][32]byte
	LastRotation, NextRotation time.Time
}

const (
	stekLockName = "stek_check"
	stekFileName = "stek/stek.bin"
)

// Interface guard
var _ caddytls.STEKProvider = (*Provider)(nil)