Skip to content

Commit e0f3fa3

Browse files
committed
add response signatures
1 parent 485ce20 commit e0f3fa3

File tree

8 files changed

+189
-13
lines changed

8 files changed

+189
-13
lines changed

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ ifdef BUILD_NODE_LOCKED
4343
ifdef BUILD_NODE_LOCKED_PORT
4444
BUILD_LDFLAGS += -X $(PACKAGE_NAME)/internal/locker.Port=$(BUILD_NODE_LOCKED_PORT)
4545
endif
46+
47+
ifdef BUILD_NODE_LOCKED_SIGNING_SECRET
48+
BUILD_LDFLAGS += -X $(PACKAGE_NAME)/internal/locker.SigningSecret=$(BUILD_NODE_LOCKED_SIGNING_SECRET)
49+
endif
4650
endif
4751

4852
ifdef DEBUG

README.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,67 @@ Accepts a `fingerprint`, the node fingerprint used for the lease.
247247
Returns `204 No Content` with no content. If a lease does not exist for the
248248
node, the server will return a `404 Not Found`.
249249

250+
## Signatures
251+
252+
Relay supports response signatures, useful for detecting simple clock tampering
253+
and spoofing attempts. When the `--signing-secret` flag is provided, all API
254+
responses will be cryptographically signed using HMAC-SHA256.
255+
256+
```
257+
Relay-Signature:
258+
t=1764949490,
259+
v1=cc22398a143ebbfc709812fdc2328ca727ed913e5e45250cfb6f3b5dfad2e72d
260+
```
261+
262+
> [!NOTE]
263+
> We provide newlines for clarity, but a real `Relay-Signature` header is on a
264+
> single line.
265+
266+
The signature `v1` is computed over the concatenation of the timestamp `t` with
267+
the raw response body, delimited by `.`. The signature will be in hexadecimal
268+
format.
269+
270+
### Verifying signatures
271+
272+
To verify a response signature from Relay:
273+
274+
#### Step 1: Extract the timestamp and signature
275+
276+
Split the `Relay-Signature` header on the `,` character to get its parts. Then
277+
split each part on `=` to obtain key–value pairs.
278+
279+
The value for `t` is the timestamp, and the value for `v1` is the signature.
280+
Discard all other parts to avoid downgrade attacks.
281+
282+
#### Step 2: Prepare the signing data
283+
284+
Construct the signed payload by concatenating:
285+
286+
- The unix timestamp `t` (as a string)
287+
- The character `.`
288+
- The raw response body (as a string)
289+
290+
Relay uses a literal period character (`.`) as the delimiter between the
291+
timestamp and the raw response body.
292+
293+
#### Step 3: Compute the signature
294+
295+
Compute an HMAC using the SHA256 hash function, using your signing secret as
296+
the key. The message is from Step 2. Hex-encode the result.
297+
298+
#### Step 4: Compare the signatures
299+
300+
Compare the received `v1` signature to the signature from Step 3. Before
301+
accepting the signature, ensure the timestamp is within your allowed tolerance
302+
window, e.g. 5 minutes, to avoid replay attacks. In addition, it's recommended
303+
to use a constant-time comparison function to avoid timing attacks.
304+
305+
> [!WARNING]
306+
> Because all signing secrets are ultimately stored locally and Relay is being
307+
> run in an untrusted offline environment, there remains the possibility of a
308+
> bad actor obtaining the signing secrets and spoofing Relay. In such cases,
309+
> we recommend taking advantage of [audit logs](#logs).
310+
250311
## Pools
251312

252313
Relay supports a concept called "pools," where, via the `--pool` flag, licenses
@@ -401,6 +462,9 @@ export BUILD_NODE_LOCKED_ADDR='0.0.0.0'
401462
# Relay port (optional)
402463
export BUILD_NODE_LOCKED_PORT='6349'
403464

465+
# Signing secret (optional)
466+
export BUILD_NODE_LOCKED_SIGNING_SECRET="hunter2"
467+
404468
# Build the node-locked binary using the above constraints
405469
BUILD_NODE_LOCKED=1 make build-linux-amd64
406470
```

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.0-beta.2
1+
1.3.0-beta.1

internal/cmd/serve.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func ServeCmd(srv server.Server) *cobra.Command {
3131
return nil
3232
})
3333

34+
router.Use(server.SigningMiddleware(cfg))
3435
router.Use(server.LoggingMiddleware)
3536

3637
// Mount the router to the server
@@ -52,13 +53,19 @@ func ServeCmd(srv server.Server) *cobra.Command {
5253
cfg.EnabledHeartbeat = !disableHeartbeats
5354
}
5455

55-
// workaround for lack of support for nullable string flags
56+
// workarounds for lack of support for nullable string flags
5657
if p, err := cmd.Flags().GetString("pool"); err == nil {
5758
if p != "" {
5859
cfg.Pool = &p
5960
}
6061
}
6162

63+
if s, err := cmd.Flags().GetString("signing-secret"); err == nil {
64+
if s != "" {
65+
cfg.SigningSecret = &s
66+
}
67+
}
68+
6269
srv.Manager().Config().Strategy = string(cfg.Strategy)
6370
srv.Manager().Config().ExtendOnHeartbeat = cfg.EnabledHeartbeat
6471

@@ -99,6 +106,12 @@ func ServeCmd(srv server.Server) *cobra.Command {
99106
cmd.Flags().IntVarP(&cfg.ServerPort, "port", "p", try.Try(try.EnvInt("RELAY_PORT"), try.EnvInt("PORT"), try.Static(cfg.ServerPort)), "port to run the relay server on [$RELAY_PORT=6349]")
100107
}
101108

109+
if locker.LockedSigningSecret() {
110+
cfg.SigningSecret = &locker.SigningSecret
111+
} else {
112+
cmd.Flags().String("signing-secret", try.Try(try.Env("RELAY_SIGNING_SECRET"), try.Static("")), "secret for signing responses [$RELAY_SIGNING_SECRET=hunter2]")
113+
}
114+
102115
cmd.Flags().DurationVar(&cfg.TTL, "ttl", try.Try(try.EnvDuration("RELAY_LEASE_TTL"), try.Static(cfg.TTL)), "time-to-live for leases [$RELAY_LEASE_TTL=60s]")
103116
cmd.Flags().Bool("no-heartbeats", try.Try(try.EnvBool("RELAY_NO_HEARTBEATS"), try.Static(false)), "disable node heartbeat monitoring and culling as well as lease extensions [$RELAY_NO_HEARTBEAT=1]")
104117
cmd.Flags().Var(&cfg.Strategy, "strategy", `strategy for license distribution e.g. "fifo", "lifo", or "rand" [$RELAY_STRATEGY=rand]`)

internal/locker/locker.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import (
1414
// locks Relay to a specific machine, depending on provided attributes. Relay will
1515
// error on mismatch, e.g. underlying IP address is different than expected IP.
1616
var (
17-
PublicKey string // required
18-
Fingerprint string // required
19-
Platform string // optional
20-
Hostname string // optional
21-
IP string // optional
22-
Addr string // optional
23-
Port string // optional
17+
PublicKey string // required
18+
Fingerprint string // required
19+
Platform string // optional
20+
Hostname string // optional
21+
IP string // optional
22+
Addr string // optional
23+
Port string // optional
24+
SigningSecret string // optional
2425
)
2526

2627
func init() {
@@ -63,6 +64,11 @@ func LockedPort() bool {
6364
return Port != ""
6465
}
6566

67+
// LockedSigningSecret returns a boolean whether or not Relay's signing secret is locked
68+
func LockedSigningSecret() bool {
69+
return SigningSecret != ""
70+
}
71+
6672
// Unlock attempts to unlock Relay via a machine file and license key using the
6773
// current machine's fingerprint
6874
func Unlock(config Config) (*keygen.MachineFileDataset, error) {

internal/server/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type Config struct {
4848
Strategy StrategyType
4949
CullInterval time.Duration
5050
Pool *string
51+
SigningSecret *string
5152
}
5253

5354
func NewConfig() *Config {

internal/server/middleware.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package server
22

33
import (
4+
"bytes"
5+
"encoding/hex"
6+
"fmt"
47
"net/http"
58
"time"
69

@@ -27,19 +30,70 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
2730

2831
func LoggingMiddleware(next http.Handler) http.Handler {
2932
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30-
33+
ww := wrapResponseWriter(w)
3134
start := time.Now()
32-
wrappedResponse := wrapResponseWriter(w)
3335

34-
next.ServeHTTP(wrappedResponse, r)
36+
next.ServeHTTP(ww, r)
3537

3638
logger.Info("HTTP request",
3739
"method", r.Method,
3840
"path", r.URL.Path,
39-
"status", wrappedResponse.Status(),
41+
"status", ww.Status(),
4042
"remote_addr", r.RemoteAddr,
4143
"user_agent", r.UserAgent(),
4244
"duration", time.Since(start),
4345
)
4446
})
4547
}
48+
49+
// signingResponseWriter captures the response body for signing
50+
type signingResponseWriter struct {
51+
http.ResponseWriter
52+
body *bytes.Buffer
53+
statusCode int
54+
}
55+
56+
func (srw *signingResponseWriter) Write(b []byte) (int, error) {
57+
return srw.body.Write(b)
58+
}
59+
60+
func (srw *signingResponseWriter) WriteHeader(code int) {
61+
srw.statusCode = code
62+
}
63+
64+
// SigningMiddleware creates a middleware that signs response bodies with HMAC-SHA256.
65+
// The signature is added as a Relay-Signature header in the format: t=<timestamp>,v1=<signature>
66+
func SigningMiddleware(cfg *Config) func(http.Handler) http.Handler {
67+
var signer *Signer
68+
69+
return func(next http.Handler) http.Handler {
70+
if signer == nil {
71+
signer = NewSigner(*cfg.SigningSecret)
72+
}
73+
74+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
75+
t := time.Now().Unix()
76+
w.Header().Set("Relay-Clock", fmt.Sprintf("%d", t))
77+
78+
if !signer.Enabled() {
79+
next.ServeHTTP(w, r)
80+
81+
return
82+
}
83+
84+
ww := &signingResponseWriter{
85+
ResponseWriter: w,
86+
body: &bytes.Buffer{},
87+
statusCode: http.StatusOK,
88+
}
89+
90+
next.ServeHTTP(ww, r)
91+
92+
sig := signer.Sign(t, ww.body.Bytes())
93+
w.Header().Set("Relay-Signature", fmt.Sprintf("t=%d,v1=%s", t, hex.EncodeToString(sig)))
94+
95+
w.WriteHeader(ww.statusCode)
96+
w.Write(ww.body.Bytes())
97+
})
98+
}
99+
}

internal/server/signer.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package server
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha256"
6+
"fmt"
7+
)
8+
9+
type Signer struct {
10+
secret []byte
11+
}
12+
13+
func NewSigner(secret string) *Signer {
14+
return &Signer{
15+
secret: []byte(secret),
16+
}
17+
}
18+
19+
// Sign generates an HMAC-SHA256 signature for the given response body
20+
// returning a signature in the format: t=<timestamp>,v1=<signature>
21+
// where the signature is computed over "<timestamp>.<message>"
22+
func (s *Signer) Sign(timestamp int64, message []byte) []byte {
23+
data := fmt.Sprintf("%d.%s", timestamp, message)
24+
25+
mac := hmac.New(sha256.New, s.secret)
26+
mac.Write([]byte(data))
27+
28+
return mac.Sum(nil)
29+
}
30+
31+
// Enabled returns true if the signer has a secret configured
32+
func (s *Signer) Enabled() bool {
33+
return len(s.secret) > 0
34+
}

0 commit comments

Comments
 (0)