Skip to content

Commit f47b0de

Browse files
committed
add response signatures
1 parent 0d56afd commit f47b0de

File tree

10 files changed

+377
-13
lines changed

10 files changed

+377
-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: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,68 @@ 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 `.` character. The signature will be in
268+
hexadecimal 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, even when [node-locked](#node-locking).
309+
> In such environments, we recommend taking advantage of [audit logs](#logs) to
310+
> periodically audit Relay.
311+
250312
## Pools
251313

252314
Relay supports a concept called "pools," where, via the `--pool` flag, licenses
@@ -401,6 +463,9 @@ export BUILD_NODE_LOCKED_ADDR='0.0.0.0'
401463
# Relay port (optional)
402464
export BUILD_NODE_LOCKED_PORT='6349'
403465

466+
# Signing secret (optional)
467+
export BUILD_NODE_LOCKED_SIGNING_SECRET="hunter2"
468+
404469
# Build the node-locked binary using the above constraints
405470
BUILD_NODE_LOCKED=1 make build-linux-amd64
406471
```

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.0
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/handler_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ package server_test
22

33
import (
44
"context"
5+
"crypto/hmac"
6+
"crypto/sha256"
7+
"encoding/hex"
58
"encoding/json"
69
"errors"
10+
"fmt"
711
"net/http"
812
"net/http/httptest"
13+
"strings"
914
"testing"
1015
"time"
1116

@@ -237,3 +242,108 @@ func TestReleaseLicense_InternalServerError(t *testing.T) {
237242
assert.Equal(t, http.StatusInternalServerError, rr.Code)
238243
assert.Contains(t, rr.Body.String(), "failed to release license")
239244
}
245+
246+
func TestClaimLicense_Signature_Enabled(t *testing.T) {
247+
secret := "test_secret"
248+
249+
cfg := server.NewConfig()
250+
cfg.SigningSecret = &secret
251+
252+
srv := testutils.NewMockServer(
253+
cfg,
254+
&testutils.FakeManager{
255+
ClaimLicenseFn: func(ctx context.Context, pool *string, fingerprint string) (*licenses.LicenseOperationResult, error) {
256+
return &licenses.LicenseOperationResult{
257+
License: &db.License{
258+
File: []byte("test_license_file"),
259+
Key: "test_license_key",
260+
},
261+
Status: licenses.OperationStatusCreated,
262+
}, nil
263+
},
264+
},
265+
)
266+
267+
handler := server.NewHandler(srv)
268+
269+
req := httptest.NewRequest(http.MethodPut, "/v1/nodes/test_fingerprint", nil)
270+
rr := httptest.NewRecorder()
271+
272+
router := mux.NewRouter()
273+
router.Use(server.SigningMiddleware(cfg))
274+
handler.RegisterRoutes(router)
275+
router.ServeHTTP(rr, req)
276+
277+
sig := rr.Header().Get("Relay-Signature")
278+
clock := rr.Header().Get("Relay-Clock")
279+
280+
assert.NotEmpty(t, sig)
281+
assert.NotEmpty(t, clock)
282+
283+
assert.True(t, verifySignature(secret, sig, rr.Body.String()))
284+
}
285+
286+
func TestClaimLicense_Signature_Disabled(t *testing.T) {
287+
cfg := server.NewConfig()
288+
srv := testutils.NewMockServer(
289+
cfg,
290+
&testutils.FakeManager{
291+
ClaimLicenseFn: func(ctx context.Context, pool *string, fingerprint string) (*licenses.LicenseOperationResult, error) {
292+
return &licenses.LicenseOperationResult{
293+
License: &db.License{
294+
File: []byte("test_license_file"),
295+
Key: "test_license_key",
296+
},
297+
Status: licenses.OperationStatusCreated,
298+
}, nil
299+
},
300+
},
301+
)
302+
303+
handler := server.NewHandler(srv)
304+
305+
req := httptest.NewRequest(http.MethodPut, "/v1/nodes/test_fingerprint", nil)
306+
rr := httptest.NewRecorder()
307+
308+
router := mux.NewRouter()
309+
router.Use(server.SigningMiddleware(cfg))
310+
handler.RegisterRoutes(router)
311+
router.ServeHTTP(rr, req)
312+
313+
sig := rr.Header().Get("Relay-Signature")
314+
clock := rr.Header().Get("Relay-Clock")
315+
316+
assert.Empty(t, sig)
317+
assert.NotEmpty(t, clock)
318+
}
319+
320+
func verifySignature(secret string, header string, body string) bool {
321+
var t, v1 string
322+
323+
for _, part := range strings.Split(header, ",") {
324+
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
325+
if len(kv) != 2 {
326+
continue
327+
}
328+
329+
switch kv[0] {
330+
case "t":
331+
t = kv[1]
332+
case "v1":
333+
v1 = kv[1]
334+
}
335+
}
336+
337+
if t == "" || v1 == "" {
338+
return false
339+
}
340+
341+
mac := hmac.New(sha256.New, []byte(secret))
342+
msg := fmt.Sprintf("%s.%s", t, body)
343+
mac.Write([]byte(msg))
344+
345+
expected := make([]byte, hex.EncodedLen(mac.Size()))
346+
hex.Encode(expected, mac.Sum(nil))
347+
348+
return hmac.Equal(expected, []byte(v1))
349+
}

internal/server/middleware.go

Lines changed: 56 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,68 @@ 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+
return func(next http.Handler) http.Handler {
68+
signer := NewSigner(cfg)
69+
70+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+
t := time.Now().Unix()
72+
w.Header().Set("Relay-Clock", fmt.Sprintf("%d", t))
73+
74+
if !signer.Enabled() {
75+
next.ServeHTTP(w, r)
76+
77+
return
78+
}
79+
80+
ww := &signingResponseWriter{
81+
ResponseWriter: w,
82+
body: &bytes.Buffer{},
83+
statusCode: http.StatusOK,
84+
}
85+
86+
next.ServeHTTP(ww, r)
87+
88+
// signature is computed over "<timestamp>.<raw response body>"
89+
msg := fmt.Sprintf("%d.%s", t, ww.body.Bytes())
90+
sig := signer.Sign([]byte(msg))
91+
w.Header().Set("Relay-Signature", fmt.Sprintf("t=%d,v1=%s", t, hex.EncodeToString(sig)))
92+
93+
w.WriteHeader(ww.statusCode)
94+
w.Write(ww.body.Bytes())
95+
})
96+
}
97+
}

0 commit comments

Comments
 (0)