Skip to content

Commit 19353ce

Browse files
committed
add response signatures
1 parent 0d56afd commit 19353ce

File tree

10 files changed

+416
-12
lines changed

10 files changed

+416
-12
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: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,69 @@ 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. The signing
255+
secret should be shared between Relay and your application.
256+
257+
```
258+
Relay-Signature:
259+
t=1764949490,
260+
v1=cc22398a143ebbfc709812fdc2328ca727ed913e5e45250cfb6f3b5dfad2e72d
261+
```
262+
263+
> [!NOTE]
264+
> We provide newlines for clarity, but a real `Relay-Signature` header is on a
265+
> single line.
266+
267+
The signature `v1` is computed over the concatenation of the timestamp `t` with
268+
the raw response body, delimited by the `.` character. The signature will be in
269+
hexadecimal format.
270+
271+
### Verifying signatures
272+
273+
To verify a response signature from Relay inside your application:
274+
275+
#### Step 1: Extract the timestamp and signature
276+
277+
Split the `Relay-Signature` header on the `,` character to get its parts. Then
278+
split each part on `=` to obtain key–value pairs.
279+
280+
The value for `t` is the timestamp, and the value for `v1` is the signature.
281+
Discard all other pairs to avoid downgrade attacks.
282+
283+
#### Step 2: Prepare the signing data
284+
285+
Construct the signed payload by concatenating:
286+
287+
- The unix timestamp `t` (as a string)
288+
- The character `.`
289+
- The raw response body (as a string)
290+
291+
Relay uses a literal period character (`.`) as the delimiter between the
292+
timestamp and the raw response body.
293+
294+
#### Step 3: Compute the signature
295+
296+
Compute an HMAC using the SHA256 hash function, using your shared signing secret
297+
as the key. The message is from Step 2. Hex-encode the result.
298+
299+
#### Step 4: Compare the signatures
300+
301+
Compare the received `v1` signature to the signature from Step 3. Before
302+
accepting the signature, ensure the timestamp is within your allowed tolerance
303+
window, e.g. 5 minutes, to avoid replay attacks. In addition, it's recommended
304+
to use a constant-time comparison function to avoid timing attacks.
305+
306+
> [!WARNING]
307+
> Because all signing secrets are ultimately stored locally and Relay is being
308+
> run in an untrusted offline environment, there remains the possibility of a
309+
> bad actor obtaining the signing secrets and spoofing Relay, even when [node-locked](#node-locking).
310+
> In such environments, we recommend taking advantage of [audit logs](#logs) to
311+
> periodically audit Relay.
312+
250313
## Pools
251314

252315
Relay supports a concept called "pools," where, via the `--pool` flag, licenses
@@ -401,6 +464,9 @@ export BUILD_NODE_LOCKED_ADDR='0.0.0.0'
401464
# Relay port (optional)
402465
export BUILD_NODE_LOCKED_PORT='6349'
403466

467+
# Signing secret (optional)
468+
export BUILD_NODE_LOCKED_SIGNING_SECRET="hunter2"
469+
404470
# Build the node-locked binary using the above constraints
405471
BUILD_NODE_LOCKED=1 make build-linux-amd64
406472
```
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# add the license
2+
exec relay add --file license.lic --key 9E32DD-D8CC22-771926-C2D834-C506DC-V3 --public-key e8601e48b69383ba520245fd07971e983d06d22c4257cfd82304601479cee788
3+
4+
# set a port as environment variable
5+
env PORT=65041
6+
7+
# start the server without signing secret
8+
exec relay serve --port $PORT &server_process_test_1&
9+
10+
# wait for the server to start
11+
exec sleep 1
12+
13+
# claim a license
14+
exec curl -s -D headers.txt -o /dev/null -w "%{http_code}" -X PUT http://localhost:$PORT/v1/nodes/test_fingerprint
15+
16+
# expect no signature header
17+
stdout '201'
18+
! exec grep 'Relay-Signature:' headers.txt
19+
20+
# kill the server
21+
kill server_process_test_1
22+
23+
# restart the server with signing secret
24+
env PORT=65042
25+
26+
exec relay serve --port $PORT --signing-secret hunter2 -vvvv &server_process_test_2&
27+
28+
# wait for the server to start
29+
exec sleep 1
30+
31+
# claim a license
32+
exec curl -s -D headers.txt -o /dev/null -w "%{http_code}" -X PUT http://localhost:$PORT/v1/nodes/test_fingerprint
33+
34+
# expect a signature header
35+
stdout '202'
36+
exec grep 'Relay-Signature:' headers.txt
37+
38+
# kill the server
39+
kill server_process_test_2

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+
}

0 commit comments

Comments
 (0)