Files
UNITRONIX 8730d9b083 feat(agent): allow HTTP/WS Support Agent profiles and fix enrollment proof reuse
LAN/IP bundles can use plaintext HTTP/WS like RustDesk while session crypto stays on the protocol layer; enrollment no longer treats a second same-request proof check as a nonce replay.
2026-08-06 01:07:55 +02:00

250 lines
8.7 KiB
Go

package api
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/unitronix/betterdesk-server/db"
)
const (
enrollmentProofClockSkew = 5 * time.Minute
enrollmentProofNonceMax = 256
)
type enrollmentProofVerifiedKey struct{}
var enrollmentProofNonceCache = &bdMgmtNonceCache{items: make(map[string]time.Time)}
// withEnrollmentProofVerified marks that this request already consumed a valid
// enrollment proof for deviceID. Later token-issue authorization must not
// verify the same nonce again (that would look like a replay).
func withEnrollmentProofVerified(r *http.Request, deviceID string) *http.Request {
if r == nil {
return r
}
return r.WithContext(context.WithValue(r.Context(), enrollmentProofVerifiedKey{}, strings.TrimSpace(deviceID)))
}
func enrollmentProofAlreadyVerified(r *http.Request, deviceID string) bool {
if r == nil {
return false
}
verified, _ := r.Context().Value(enrollmentProofVerifiedKey{}).(string)
return verified != "" && verified == strings.TrimSpace(deviceID)
}
// enrollmentProofPayload is deliberately distinct from the management-channel
// signature format. A valid management proof must not be replayable to mint a
// device token, and vice versa.
func enrollmentProofPayload(method, path, deviceID, publicKey, timestamp, nonce string) []byte {
return fmt.Appendf(nil,
"bd-enrollment-v1\n%s\n%s\n%s\n%s\n%s\n%s",
method, path, deviceID, publicKey, timestamp, nonce,
)
}
// verifyEnrollmentDeviceProof verifies a short-lived proof of possession for
// the device identity. Existing peers must use their already-bound key; an
// untrusted request cannot replace that key. A first registration may prove
// possession of the public_key it is binding, which is sufficient in open mode
// where admission itself is intentionally unrestricted.
func (s *Server) verifyEnrollmentDeviceProof(r *http.Request, deviceID, incomingPublicKey string) error {
timestamp := strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Timestamp"))
nonce := strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Nonce"))
signature := strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Signature"))
if timestamp == "" || nonce == "" || signature == "" {
return fmt.Errorf("missing enrollment proof headers")
}
if len(nonce) > enrollmentProofNonceMax {
return fmt.Errorf("enrollment proof nonce is too long")
}
signedAt, err := time.Parse(time.RFC3339, timestamp)
if err != nil {
return fmt.Errorf("invalid enrollment proof timestamp: %w", err)
}
now := time.Now().UTC()
delta := now.Sub(signedAt.UTC())
if delta < 0 {
delta = -delta
}
if delta > enrollmentProofClockSkew {
return fmt.Errorf("enrollment proof timestamp outside allowed skew")
}
canonicalKey, publicKey, err := s.enrollmentProofPublicKey(deviceID, incomingPublicKey)
if err != nil {
return err
}
decodedSignature, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return fmt.Errorf("invalid enrollment proof signature encoding: %w", err)
}
if len(decodedSignature) != ed25519.SignatureSize {
return fmt.Errorf("invalid enrollment proof signature length")
}
payload := enrollmentProofPayload(r.Method, r.URL.Path, deviceID, canonicalKey, timestamp, nonce)
if !ed25519.Verify(ed25519.PublicKey(publicKey), payload, decodedSignature) {
return fmt.Errorf("invalid enrollment proof signature")
}
if enrollmentProofNonceCache.markUsed("enrollment:"+deviceID+":"+nonce, now) {
return fmt.Errorf("replayed enrollment proof")
}
return nil
}
// enrollmentProofPublicKey returns the only key that may authenticate an
// enrollment token issuance for deviceID. Pending metadata is trusted only
// because it was captured on the initial request and is immutable afterward.
func (s *Server) enrollmentProofPublicKey(deviceID, incomingPublicKey string) (string, []byte, error) {
if s.db == nil {
return "", nil, fmt.Errorf("device database unavailable")
}
peerInfo, err := s.db.GetPeer(deviceID)
if err != nil {
return "", nil, fmt.Errorf("load enrolled device: %w", err)
}
if peerInfo != nil {
publicKey, err := s.loadBdMgmtPublicKey(deviceID)
if err != nil {
return "", nil, fmt.Errorf("no bound device identity: %w", err)
}
canonical := base64.StdEncoding.EncodeToString(publicKey)
if incomingPublicKey != "" {
incomingCanonical, err := canonicalizeDevicePublicKey(incomingPublicKey)
if err != nil {
return "", nil, err
}
if incomingCanonical != canonical {
return "", nil, fmt.Errorf("public_key does not match bound device identity")
}
}
return canonical, publicKey, nil
}
if pending, ok := s.pendingEnrollmentForProof(deviceID); ok && pending.PublicKey != "" {
canonical, err := canonicalizeDevicePublicKey(pending.PublicKey)
if err != nil {
return "", nil, fmt.Errorf("invalid pending device public key: %w", err)
}
if incomingPublicKey != "" {
incomingCanonical, err := canonicalizeDevicePublicKey(incomingPublicKey)
if err != nil {
return "", nil, err
}
if incomingCanonical != canonical {
return "", nil, fmt.Errorf("public_key does not match pending device identity")
}
}
publicKey, err := base64.StdEncoding.DecodeString(canonical)
if err != nil {
return "", nil, err
}
return canonical, publicKey, nil
}
canonical, err := canonicalizeDevicePublicKey(incomingPublicKey)
if err != nil {
return "", nil, fmt.Errorf("public_key required for initial enrollment proof: %w", err)
}
publicKey, err := base64.StdEncoding.DecodeString(canonical)
if err != nil {
return "", nil, err
}
return canonical, publicKey, nil
}
func (s *Server) pendingEnrollmentForProof(deviceID string) (pendingDeviceInfo, bool) {
var pending pendingDeviceInfo
raw, err := s.db.GetConfig(pendingDevicePrefix + deviceID)
if err != nil || raw == "" {
return pending, false
}
if err := json.Unmarshal([]byte(raw), &pending); err != nil || pending.DeviceID == "" {
return pendingDeviceInfo{}, false
}
return pending, true
}
// enrollmentTokenCandidates reads credentials only from a POST body field or
// the standard Authorization header. Tokens are intentionally never accepted
// from query parameters, which are commonly retained by access logs and
// intermediaries.
func enrollmentTokenCandidates(r *http.Request, bodyToken string) []string {
candidates := make([]string, 0, 2)
if token := strings.TrimSpace(bodyToken); token != "" {
candidates = append(candidates, token)
}
if authorization := r.Header.Get("Authorization"); len(authorization) > len("Bearer ") &&
strings.EqualFold(authorization[:len("Bearer ")], "Bearer ") {
if token := strings.TrimSpace(authorization[len("Bearer "):]); token != "" {
candidates = append(candidates, token)
}
}
return candidates
}
// enrollmentProofProvided distinguishes a legacy credential-only request from
// a request that attempted proof-of-possession. The latter must not fall back
// to the bearer credential after an invalid or replayed proof: otherwise a
// captured request could mint another token despite nonce replay protection.
func enrollmentProofProvided(r *http.Request) bool {
if r == nil {
return false
}
return strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Timestamp")) != "" ||
strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Nonce")) != "" ||
strings.TrimSpace(r.Header.Get("X-BD-Enrollment-Signature")) != ""
}
// hasEnrollmentCredential accepts a valid device/enrollment token only when
// it is either unbound (for first enrollment) or bound to the exact device.
// Reissuance always requires an active, device-bound credential.
func (s *Server) hasEnrollmentCredential(deviceID string, candidates []string, requireBound bool) bool {
for _, candidate := range candidates {
token, err := s.db.ValidateToken(hashToken(candidate))
if err != nil || token == nil {
continue
}
if requireBound {
if token.Status == db.TokenStatusActive && token.PeerID == deviceID {
return true
}
continue
}
if token.PeerID == "" || token.PeerID == deviceID {
return true
}
}
return false
}
// authorizeEnrollmentTokenIssue requires a credential that is already tied to
// the device, or a replay-protected proof made by that device's private key.
func (s *Server) authorizeEnrollmentTokenIssue(r *http.Request, deviceID, publicKey, bodyToken string, requireBoundToken bool) bool {
if r == nil {
return false
}
if enrollmentProofAlreadyVerified(r, deviceID) {
return true
}
if enrollmentProofProvided(r) {
// Do not fall back to a valid bearer token here. A replayed proof must
// be rejected rather than bypassing the nonce cache through Authorization.
return s.verifyEnrollmentDeviceProof(r, deviceID, publicKey) == nil
}
if s.hasEnrollmentCredential(deviceID, enrollmentTokenCandidates(r, bodyToken), requireBoundToken) {
return true
}
return false
}