summaryrefslogtreecommitdiffhomepage
path: root/ios/MullvadVPN/TunnelManager/WgKeyRotation.swift
blob: d887f951733444ae13df956ea2750a4c385b7988 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
//
//  WgKeyRotation.swift
//  MullvadVPN
//
//  Created by pronebird on 24/05/2023.
//  Copyright © 2025 Mullvad VPN AB. All rights reserved.
//

import Foundation
import MullvadSettings
import MullvadTypes
@preconcurrency import WireGuardKitTypes

/**
 Implements manipulations related to marking the beginning and the completion of key rotation, private key creation and other tasks relevant to handling the state of
 key rotation.
 */
struct WgKeyRotation: Sendable {
    /// Private key rotation interval counted from the time when the key was successfully pushed
    /// to the backend.
    public static let rotationInterval: Duration = .days(30)

    /// Private key rotation retry interval counted from the time when the last rotation
    /// attempt took place.
    public static let retryInterval: Duration = .days(1)

    /// Cooldown interval used to prevent packet tunnel from forcefully pushing the key to our
    /// backend in the event of restart loop.
    public static let packetTunnelCooldownInterval: Duration = .seconds(15)

    /// Mutated device data value.
    private(set) var data: StoredDeviceData

    /// Initialize object with `StoredDeviceData` that the struct is going to manipulate.
    init(data: StoredDeviceData) {
        self.data = data
    }

    /**
     Begin key rotation attempt by marking last rotation attempt and creating next private key if needed.
     If the next private key was created during the preivous rotation attempt then continue using the same key.
    
     Returns the public key that should be pushed to the backend.
     */
    mutating func beginAttempt() -> PublicKey {
        // Mark the rotation attempt.
        data.wgKeyData.lastRotationAttemptDate = Date()

        // Fetch the next private key we're attempting to rotate to.
        if let nextPrivateKey = data.wgKeyData.nextPrivateKey {
            return nextPrivateKey.publicKey
        } else {
            // If not found then create a new one and store it.
            let newKey = PrivateKey()
            data.wgKeyData.nextPrivateKey = newKey
            return newKey.publicKey
        }
    }

    /**
     Successfuly finish key rotation by swapping the current key with the next one, marking key creation date and
     removing the date of last rotation attempt which indicates that the last rotation had succedeed and no new
     rotation attempts were made.
    
     Device related properties are refreshed from `Device` struct that the caller should have received from the API. This function does nothing if the next private
     key is unset.
    
     Returns `false` if next private key is unset. Otherwise `true`.
     */
    mutating func setCompleted(with updatedDevice: Device) -> Bool {
        guard let nextKey = data.wgKeyData.nextPrivateKey else { return false }

        // Update stored device data with properties from updated `Device` struct.
        data.update(from: updatedDevice)

        // Reset creation date so that next period key rotation could happen relative to this date.
        data.wgKeyData.creationDate = Date()

        // Swap old and new keys.
        data.wgKeyData.privateKey = nextKey
        data.wgKeyData.nextPrivateKey = nil

        // Unset the date of last rotation attempt to mark the end of key rotation sequence.
        data.wgKeyData.lastRotationAttemptDate = nil

        return true
    }

    /**
     Returns the date of next key rotation, as it normally occurs in the app process using the following rules:
    
     1. Returns the date relative to key creation date + 30 days, if last rotation attempt was successful.
     2. Returns the date relative to last rotation attempt date + 24 hours, if last rotation attempt was unsuccessful.
    
     If the date produced is in the past then `Date()` is returned instead.
     */
    var nextRotationDate: Date {
        let nextRotationDate =
            data.wgKeyData.lastRotationAttemptDate?
            .addingTimeInterval(Self.retryInterval.timeInterval)
            ?? data.wgKeyData.creationDate.addingTimeInterval(Self.rotationInterval.timeInterval)

        return max(nextRotationDate, Date())
    }

    /// Returns `true` if the app should rotate the private key.
    var shouldRotate: Bool {
        nextRotationDate <= Date()
    }

    /**
     Returns `true` if packet tunnel should perform key rotation.
    
     During the startup packet tunnel rotates the key immediately if it detected that the key stored on server does not
     match the key stored on device. In that case it passes `rotateImmediately = true`.
    
     To dampen the effect of packet tunnel entering into a restart cycle and going on a key rotation rampage,
     this function adds a 15 seconds cooldown interval to prevent it from pushing keys too often.
    
     After performing the initial key rotation on startup, packet tunnel will keep a 24 hour interval between the
     subsequent key rotation attempts.
     */
    func shouldRotateFromPacketTunnel(rotateImmediately: Bool) -> Bool {
        guard let lastRotationAttemptDate = data.wgKeyData.lastRotationAttemptDate else { return true }

        let now = Date()

        // Add cooldown interval when requested to rotate the key immediately.
        if rotateImmediately, lastRotationAttemptDate.distance(to: now) > Self.packetTunnelCooldownInterval {
            return true
        }

        let nextRotationAttempt = max(now, lastRotationAttemptDate.addingTimeInterval(Self.retryInterval.timeInterval))
        if nextRotationAttempt <= now {
            return true
        }

        return false
    }
}