Files
netbird/client/internal/updater/reposign/revocation.go
Zoltan Papp fe9b844511 [client] refactor auto update workflow (#5448)
Auto-update logic moved out of the UI into a dedicated updatemanager.Manager service that runs in the connection layer. The
UI no longer polls or checks for updates independently.
The update manager supports three modes driven by the management server's auto-update policy:
No policy set by mgm: checks GitHub for the latest version and notifies the user (previous behavior, now centralized)
mgm enforces update: the "About" menu triggers installation directly instead of just downloading the file — user still initiates the action
mgm forces update: installation proceeds automatically without user interaction
updateManager lifecycle is now owned by daemon, giving the daemon server direct control via a new TriggerUpdate RPC
Introduces EngineServices struct to group external service dependencies passed to NewEngine, reducing its argument count from 11 to 4
2026-03-13 17:01:28 +01:00

230 lines
6.8 KiB
Go

package reposign
import (
"crypto/ed25519"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"time"
log "github.com/sirupsen/logrus"
)
const (
maxRevocationSignatureAge = 10 * 365 * 24 * time.Hour
defaultRevocationListExpiration = 365 * 24 * time.Hour
)
type RevocationList struct {
Revoked map[KeyID]time.Time `json:"revoked"` // KeyID -> revocation time
LastUpdated time.Time `json:"last_updated"` // When the list was last modified
ExpiresAt time.Time `json:"expires_at"` // When the list expires
}
func (rl RevocationList) MarshalJSON() ([]byte, error) {
// Convert map[KeyID]time.Time to map[string]time.Time
strMap := make(map[string]time.Time, len(rl.Revoked))
for k, v := range rl.Revoked {
strMap[k.String()] = v
}
return json.Marshal(map[string]interface{}{
"revoked": strMap,
"last_updated": rl.LastUpdated,
"expires_at": rl.ExpiresAt,
})
}
func (rl *RevocationList) UnmarshalJSON(data []byte) error {
var temp struct {
Revoked map[string]time.Time `json:"revoked"`
LastUpdated time.Time `json:"last_updated"`
ExpiresAt time.Time `json:"expires_at"`
Version int `json:"version"`
}
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
// Convert map[string]time.Time back to map[KeyID]time.Time
rl.Revoked = make(map[KeyID]time.Time, len(temp.Revoked))
for k, v := range temp.Revoked {
kid, err := ParseKeyID(k)
if err != nil {
return fmt.Errorf("failed to parse KeyID %q: %w", k, err)
}
rl.Revoked[kid] = v
}
rl.LastUpdated = temp.LastUpdated
rl.ExpiresAt = temp.ExpiresAt
return nil
}
func ParseRevocationList(data []byte) (*RevocationList, error) {
var rl RevocationList
if err := json.Unmarshal(data, &rl); err != nil {
return nil, fmt.Errorf("failed to unmarshal revocation list: %w", err)
}
// Initialize the map if it's nil (in case of empty JSON object)
if rl.Revoked == nil {
rl.Revoked = make(map[KeyID]time.Time)
}
if rl.LastUpdated.IsZero() {
return nil, fmt.Errorf("revocation list missing last_updated timestamp")
}
if rl.ExpiresAt.IsZero() {
return nil, fmt.Errorf("revocation list missing expires_at timestamp")
}
return &rl, nil
}
func ValidateRevocationList(publicRootKeys []PublicKey, data []byte, signature Signature) (*RevocationList, error) {
revoList, err := ParseRevocationList(data)
if err != nil {
log.Debugf("failed to parse revocation list: %s", err)
return nil, err
}
now := time.Now().UTC()
// Validate signature timestamp
if signature.Timestamp.After(now.Add(maxClockSkew)) {
err := fmt.Errorf("revocation signature timestamp is in the future: %v", signature.Timestamp)
log.Debugf("revocation list signature error: %v", err)
return nil, err
}
if now.Sub(signature.Timestamp) > maxRevocationSignatureAge {
err := fmt.Errorf("revocation list signature is too old: %v (created %v)",
now.Sub(signature.Timestamp), signature.Timestamp)
log.Debugf("revocation list signature error: %v", err)
return nil, err
}
// Ensure LastUpdated is not in the future (with clock skew tolerance)
if revoList.LastUpdated.After(now.Add(maxClockSkew)) {
err := fmt.Errorf("revocation list LastUpdated is in the future: %v", revoList.LastUpdated)
log.Errorf("rejecting future-dated revocation list: %v", err)
return nil, err
}
// Check if the revocation list has expired
if now.After(revoList.ExpiresAt) {
err := fmt.Errorf("revocation list expired at %v (current time: %v)", revoList.ExpiresAt, now)
log.Errorf("rejecting expired revocation list: %v", err)
return nil, err
}
// Ensure ExpiresAt is not in the future by more than the expected expiration window
// (allows some clock skew but prevents maliciously long expiration times)
if revoList.ExpiresAt.After(now.Add(maxRevocationSignatureAge)) {
err := fmt.Errorf("revocation list ExpiresAt is too far in the future: %v", revoList.ExpiresAt)
log.Errorf("rejecting revocation list with invalid expiration: %v", err)
return nil, err
}
// Validate signature timestamp is close to LastUpdated
// (prevents signing old lists with new timestamps)
timeDiff := signature.Timestamp.Sub(revoList.LastUpdated).Abs()
if timeDiff > maxClockSkew {
err := fmt.Errorf("signature timestamp %v differs too much from list LastUpdated %v (diff: %v)",
signature.Timestamp, revoList.LastUpdated, timeDiff)
log.Errorf("timestamp mismatch in revocation list: %v", err)
return nil, err
}
// Reconstruct the signed message: revocation_list_data || timestamp || version
msg := make([]byte, 0, len(data)+8)
msg = append(msg, data...)
msg = binary.LittleEndian.AppendUint64(msg, uint64(signature.Timestamp.Unix()))
if !verifyAny(publicRootKeys, msg, signature.Signature) {
return nil, errors.New("revocation list verification failed")
}
return revoList, nil
}
func CreateRevocationList(privateRootKey RootKey, expiration time.Duration) ([]byte, []byte, error) {
now := time.Now()
rl := RevocationList{
Revoked: make(map[KeyID]time.Time),
LastUpdated: now.UTC(),
ExpiresAt: now.Add(expiration).UTC(),
}
signature, err := signRevocationList(privateRootKey, rl)
if err != nil {
return nil, nil, fmt.Errorf("failed to sign revocation list: %w", err)
}
rlData, err := json.Marshal(&rl)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal revocation list: %w", err)
}
signData, err := json.Marshal(signature)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal signature: %w", err)
}
return rlData, signData, nil
}
func ExtendRevocationList(privateRootKey RootKey, rl RevocationList, kid KeyID, expiration time.Duration) ([]byte, []byte, error) {
now := time.Now().UTC()
rl.Revoked[kid] = now
rl.LastUpdated = now
rl.ExpiresAt = now.Add(expiration)
signature, err := signRevocationList(privateRootKey, rl)
if err != nil {
return nil, nil, fmt.Errorf("failed to sign revocation list: %w", err)
}
rlData, err := json.Marshal(&rl)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal revocation list: %w", err)
}
signData, err := json.Marshal(signature)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal signature: %w", err)
}
return rlData, signData, nil
}
func signRevocationList(privateRootKey RootKey, rl RevocationList) (*Signature, error) {
data, err := json.Marshal(rl)
if err != nil {
return nil, fmt.Errorf("failed to marshal revocation list for signing: %w", err)
}
timestamp := time.Now().UTC()
msg := make([]byte, 0, len(data)+8)
msg = append(msg, data...)
msg = binary.LittleEndian.AppendUint64(msg, uint64(timestamp.Unix()))
sig := ed25519.Sign(privateRootKey.Key, msg)
signature := &Signature{
Signature: sig,
Timestamp: timestamp,
KeyID: privateRootKey.Metadata.ID,
Algorithm: "ed25519",
HashAlgo: "sha512",
}
return signature, nil
}