Skip to content

Commit bf61408

Browse files
authored
Merge commit from fork
Advisory fix
2 parents 935be05 + 374339e commit bf61408

11 files changed

Lines changed: 720 additions & 71 deletions

File tree

api/network.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"encoding/hex"
2020
"fmt"
2121
"regexp"
22+
"strconv"
2223
"strings"
2324
"time"
2425

@@ -74,6 +75,33 @@ const (
7475
TokenRefreshCheckInterval = 10 * time.Minute
7576
)
7677

78+
// EnrollChallenge is the payload a bootstrap enrollee signs to prove
79+
// possession of the private half of BootstrapEnrollRequest.public_key at
80+
// POST /enroll, carried in that message's timestamp/challenge_signature
81+
// fields. Binding the peer ID keeps a captured signature useless for any
82+
// other peer; the domain prefix keeps it useless at any other endpoint.
83+
func EnrollChallenge(peerID string, ts int64) []byte {
84+
return []byte("sam:enroll:" + peerID + ":" + strconv.FormatInt(ts, 10))
85+
}
86+
87+
// EnrollStatusChallenge is the payload a bootstrap enrollee signs to prove
88+
// possession of the key it submitted at /enroll when polling
89+
// GET /enroll/status. ts is unix milliseconds; the signature travels in the
90+
// HeaderChallengeSignature header (unpadded base64url) alongside
91+
// HeaderChallengeTimestamp.
92+
func EnrollStatusChallenge(peerID string, ts int64) []byte {
93+
return []byte("sam:enroll-status:" + peerID + ":" + strconv.FormatInt(ts, 10))
94+
}
95+
96+
// RefreshChallenge is the payload an enrolled peer signs to prove possession
97+
// of its identity key at POST /refresh, carried in TokenRefreshRequest's
98+
// timestamp/challenge_signature fields alongside the expiring biscuit. Same
99+
// shape as the other enrollment challenges: peer- and endpoint-bound, so a
100+
// captured signature is useless anywhere else.
101+
func RefreshChallenge(peerID string, ts int64) []byte {
102+
return []byte("sam:refresh:" + peerID + ":" + strconv.FormatInt(ts, 10))
103+
}
104+
77105
// ============================================================================
78106
// SAM Custom HTTP Headers
79107
// ============================================================================
@@ -87,6 +115,14 @@ const (
87115
// are forwarded to backend services.
88116
HeaderSamBiscuit = "X-Sam-Biscuit"
89117

118+
// HeaderChallengeTimestamp and HeaderChallengeSignature carry the signed
119+
// freshness challenge on GET /enroll/status: unix milliseconds and an
120+
// unpadded base64url signature over EnrollStatusChallenge. Headers rather
121+
// than query parameters, so the signature never lands in access logs,
122+
// where it would be replayable for its freshness window.
123+
HeaderChallengeTimestamp = "X-Sam-Challenge-Ts"
124+
HeaderChallengeSignature = "X-Sam-Challenge-Sig"
125+
90126
// HeaderPeerID carries the authenticated libp2p peer ID of the caller.
91127
// The mesh ingress handler stamps it after authorization succeeds,
92128
// overwriting any inbound value, so backend services get verified caller

api/sam.pb.go

Lines changed: 39 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/sam.proto

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,22 @@ message BootstrapEnrollRequest {
8585
// Operator-declared labels; the admin approving the enrollment attests
8686
// them (see EnrollRequest.labels).
8787
map<string, string> labels = 5;
88-
}
89-
88+
// Proof of possession of public_key's private half: timestamp is unix
89+
// milliseconds and challenge_signature signs the UTF-8 bytes of
90+
// "sam:enroll:<peer_id>:<timestamp>". Required, and peer_id must be
91+
// derived from public_key: this is what entitles a repeated POST /enroll
92+
// to re-fetch an existing enrollment's biscuit, so a bootstrap token
93+
// alone must never satisfy it.
94+
int64 timestamp = 6;
95+
bytes challenge_signature = 7;
96+
}
97+
98+
// BootstrapEnrollResponse answers POST /enroll and GET /enroll/status.
99+
// Polling GET /enroll/status requires proof of possession of the key
100+
// submitted at /enroll: alongside the `peer_id` query parameter, the caller
101+
// sends the X-Sam-Challenge-Ts header (unix milliseconds) and the
102+
// X-Sam-Challenge-Sig header (unpadded base64url signature over the UTF-8
103+
// bytes of "sam:enroll-status:<peer_id>:<ts>").
90104
message BootstrapEnrollResponse {
91105
EnrollmentStatus status = 1;
92106
bytes biscuit_token = 2; // Populated only if APPROVED
@@ -216,7 +230,10 @@ message KeysResponse {
216230
}
217231

218232
message TokenRefreshRequest {
219-
// Signature over the decimal string form of timestamp, made with the node key.
233+
// Signature with the node key over the UTF-8 bytes of
234+
// "sam:refresh:<peer_id>:<timestamp>", where peer_id is the one bound in
235+
// the presented biscuit. Peer- and endpoint-bound so a captured signature
236+
// verifies nowhere else.
220237
bytes challenge_signature = 1;
221238
// Unix milliseconds. Must be within the control plane's freshness window.
222239
int64 timestamp = 2;

internal/controlplane/approval_labels_test.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,19 @@ func TestApprovalRefusesLabelsTheRoleDoesNotGrant(t *testing.T) {
9696
t.Fatal(err)
9797
}
9898

99+
enrollTS := time.Now().UnixMilli()
100+
enrollSig, err := priv.Sign(api.EnrollChallenge(pID.String(), enrollTS))
101+
if err != nil {
102+
t.Fatal(err)
103+
}
99104
enrollData, err := proto.Marshal(&api.BootstrapEnrollRequest{
100-
BootstrapToken: tokenDetails.Token,
101-
PeerId: pID.String(),
102-
PublicKey: pubBytes,
103-
RequestedRole: api.RoleNode,
104-
Labels: labels,
105+
BootstrapToken: tokenDetails.Token,
106+
PeerId: pID.String(),
107+
PublicKey: pubBytes,
108+
RequestedRole: api.RoleNode,
109+
Labels: labels,
110+
Timestamp: enrollTS,
111+
ChallengeSignature: enrollSig,
105112
})
106113
if err != nil {
107114
t.Fatal(err)

internal/controlplane/biscuit_ttl_test.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,11 @@ func refreshNode(t *testing.T, cpURL string, priv crypto.PrivKey, currentBiscuit
119119
t.Helper()
120120

121121
timestamp := time.Now().UnixMilli()
122-
sig, err := priv.Sign([]byte(fmt.Sprintf("%d", timestamp)))
122+
pid, err := peer.IDFromPrivateKey(priv)
123+
if err != nil {
124+
t.Fatal(err)
125+
}
126+
sig, err := priv.Sign(api.RefreshChallenge(pid.String(), timestamp))
123127
if err != nil {
124128
t.Fatal(err)
125129
}

0 commit comments

Comments
 (0)