# iOS Agent — API Compatibility Reference **Status**: API CONTRACT **Mirror**: Android Agent (`apps/android-agent/`) ## Overview The iOS agent communicates with the server via two HTTP endpoints: 1. **Provisioning**: `POST /api/v1/provision` — Initial key exchange 2. **Heartbeat**: `POST /api/v1/heartbeat` — Periodic status + config sync ## 1. Provisioning ### Request ``` POST /api/v1/provision Content-Type: application/json ``` ```json { "token": "string (required)", "hwid": "string (required)" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `token` | string | Yes | Registration token (single-use) | | `hwid` | string | Yes | Hardware ID (iOS identifierForVendor) | ### Response (Success) ``` HTTP/1.1 200 OK Content-Type: application/json ``` ```json { "encrypted_config": "base64-encoded-bytes" } ``` | Field | Type | Description | |-------|------|-------------| | `encrypted_config` | string | AES-256-GCM encrypted ConfigPayload | ### Response (Error) ```json { "error": "string" } ``` | Status | Error | Description | |--------|-------|-------------| | 400 | `"invalid token format"` | Token is not valid UUID | | 400 | `"token already used"` | Token was already consumed | | 400 | `"invalid hardware id"` | HWID is empty or invalid | | 403 | `"token expired"` | Token has expired (24h TTL) | | 500 | `"provisioning failed"` | Server internal error | ### Decrypted ConfigPayload After decrypting `encrypted_config`, the agent receives: ```json { "device_id": "string (UUID)", "private_key": "string (hex, 64 chars)", "preshared_key": "string (hex, 64 chars)", "internal_ip": "string (e.g. 10.172.21.2)", "server_pub": "string (hex, 64 chars)", "endpoint": "string (e.g. italy-twenty.gl.at.ply.gg:59750)", "dns": "string (e.g. 1.1.1.1)", "allowed_ips": "string (e.g. 0.0.0.0/0)", "server_wg_ip": "string (e.g. 10.172.21.1)" } ``` | Field | Type | Description | |-------|------|-------------| | `device_id` | string | Device UUID (use in heartbeat) | | `private_key` | string | WireGuard private key (hex) | | `preshared_key` | string | Pre-shared key (hex) | | `internal_ip` | string | Device IP address | | `server_pub` | string | Server WireGuard public key (hex) | | `endpoint` | string | Server WireGuard endpoint | | `dns` | string | DNS server IP | | `allowed_ips` | string | Allowed IPs (e.g. "0.0.0.0/0" for full tunnel) | | `server_wg_ip` | string | Server WireGuard IP | ## 2. Heartbeat ### Request ``` POST /api/v1/heartbeat Content-Type: application/json ``` ```json { "device_id": "string (required)", "status": "string (optional)", "state": "string (optional)", "tunnel_up": "boolean (optional)", "last_handshake": "string (optional, ISO 8601)" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `device_id` | string | Yes | Device UUID | | `status` | string | No | "connected", "recovering", "stopped" | | `state` | string | No | Free-form state description | | `tunnel_up` | boolean | No | WireGuard tunnel status | | `last_handshake` | string | No | ISO 8601 timestamp of last handshake | ### Response (Success) ``` HTTP/1.1 200 OK Content-Type: application/json ``` ```json { "device_id": "string", "status": "ok", "internal_ip": "string", "private_key": "string (hex)", "preshared_key": "string (hex)", "server_pub": "string (hex)", "endpoint": "string", "server_wg_ip": "string", "dns": "string", "allowed_ips": "string", "forwards_hash": "string" } ``` | Field | Type | Description | |-------|------|-------------| | `device_id` | string | Device UUID | | `status` | string | "ok" or error | | `internal_ip` | string | Device IP (may change) | | `private_key` | string | WireGuard private key (may rotate) | | `preshared_key` | string | Pre-shared key (may rotate) | | `server_pub` | string | Server public key | | `endpoint` | string | Server endpoint | | `server_wg_ip` | string | Server WireGuard IP | | `dns` | string | DNS server | | `allowed_ips` | string | Allowed IPs | | `forwards_hash` | string | Hash of port forwards (for change detection) | ### Response (Error) ```json { "error": "string" } ``` | Status | Error | Description | |--------|-------|-------------| | 400 | `"invalid device id"` | Device ID is not valid UUID | | 403 | `"device is suspended"` | Device is suspended by admin | | 500 | `"heartbeat failed"` | Server internal error | ## 3. Crypto Protocol ### Key Derivation ``` key = SHA256(hwid + salt) ``` - `hwid`: Hardware ID (iOS `identifierForVendor`) - `salt`: Server's `SERVER_SALT` environment variable - Output: 32-byte AES key ### Encryption (Server Side) ``` plaintext = ConfigPayload JSON nonce = random 12 bytes ciphertext = AES-256-GCM-Encrypt(key, nonce, plaintext) encrypted_config = nonce + ciphertext ``` ### Decryption (Agent Side) ```swift let combined = Data(base64Encoded: encryptedConfig)! let nonce = combined.prefix(12) let ciphertext = combined.dropFirst(12) let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: ciphertext) let plaintext = try AES.GCM.open(sealedBox, using: key) let json = try JSONSerialization.jsonObject(with: plaintext) ``` ### Implementation Notes - Use `CryptoKit` for AES-256-GCM - Use `CryptoKit` for SHA-256 key derivation - Nonce size: 12 bytes (fixed for GCM) - Tag size: 16 bytes (default for GCM) ## 4. Config Change Detection ### Heartbeat Hash Comparison The server returns `forwards_hash` in heartbeat response. Agent should: 1. Store last received `forwards_hash` in UserDefaults 2. Compare with current hash 3. If different, trigger config reload ```swift let lastHash = UserDefaults.standard.string(forKey: "config_hash") ?? "" if lastHash != response.forwardsHash { // Config changed, rebuild tunnel tunnelManager.rebuildTunnel(with: newConfig) UserDefaults.standard.set(response.forwardsHash, forKey: "config_hash") } ``` ## 5. Hardware ID ### iOS identifierForVendor ```swift import UIKit let hwid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown" ``` ### Alternative: Custom Serial ```swift func getHardwareId() -> String { // Use Keychain to persist custom HWID if let savedHwid = KeychainManager.get(key: "hwid") { return savedHwid } let newHwid = UUID().uuidString KeychainManager.set(key: "hwid", value: newHwid) return newHwid } ``` ## 6. Versioning ### Current Version - API Version: v1 (implicit, no version in URL) - Agent Version: 1.0.0 ### Compatibility Policy - **Breaking Changes**: Major version bump (v2) - **New Fields**: Added without version bump (agent ignores unknown fields) - **Deprecation**: 6-month notice before removal ### Agent Behavior - Agent ignores unknown fields in server response - Agent sends only required fields in request - Agent handles missing optional fields gracefully ## 7. Examples ### Provisioning Flow ```swift // 1. Get hardware ID let hwid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown" // 2. Send provisioning request let body = ["token": token, "hwid": hwid] let request = URLRequest(url: URL(string: "\(serverUrl)/api/v1/provision")!) // 3. Parse encrypted config let encrypted = try JSONDecoder().decode([String: String].self, from: data)["encrypted_config"]! // 4. Decrypt config let config = try CryptoManager.decryptConfig(encrypted: encrypted, hwid: hwid, salt: salt) // 5. Store in Keychain KeychainStorage.save(config) // 6. Apply to WireGuard try await tunnelManager.startTunnel(config: config) ``` ### Heartbeat Flow ```swift // 1. Get tunnel status let tunnelUp = tunnelManager.isTunnelUp() let lastHandshake = tunnelManager.getLastHandshake() // 2. Send heartbeat let body: [String: Any] = [ "device_id": config.deviceId, "tunnel_up": tunnelUp, "last_handshake": lastHandshake?.iso8601String ?? "1970-01-01T00:00:00Z" ] // 3. Check for config changes let response = try JSONDecoder().decode(HeartbeatResponse.self, from: data) if config.configHash != response.forwardsHash { // Config changed, reload let newConfig = try parseConfig(from: response) tunnelManager.rebuildTunnel(with: newConfig) KeychainStorage.save(newConfig) } ``` ## 8. Error Handling ### HTTP Retries ```swift func sendRequest(url: URL, body: Data, maxRetries: Int = 3) async throws -> Data { for attempt in 1...maxRetries { do { let (data, response) = try await URLSession.shared.data(for: request) if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 { return data } } catch { if attempt == maxRetries { throw error } try await Task.sleep(nanoseconds: UInt64(attempt * 1_000_000_000)) } } throw AgentError.heartbeatFailed("Max retries exceeded") } ``` ### Timeout Settings - Connect timeout: 10 seconds - Read timeout: 30 seconds - Write timeout: 10 seconds ## 9. Security Notes - All communication over HTTPS (TLS 1.2+) - Registration token is single-use (consumed on first provisioning) - Device can re-provision by obtaining new token - Keys never logged or transmitted in plaintext - WireGuard keys stored in iOS Keychain (hardware-backed)