Files
Nexus-Guard-Suite/reference/ios-agent/docs/API_COMPAT.md
T
datadunia 84689b208e docs: add ESP32 and iOS agent architecture reference
- reference/esp32-agent/: DESIGN, HARDWARE, API_COMPAT, ARCHITECTURE, README

- reference/ios-agent/: DESIGN, API_COMPAT, UI_DESIGN, ARCHITECTURE, README

- Both mirror Android agent architecture, UI design, and heartbeat flow

- Architecture-only (no code) — avoids submodule conflicts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-06 14:20:10 +07:00

9.1 KiB

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
{
  "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
{
  "encrypted_config": "base64-encoded-bytes"
}
Field Type Description
encrypted_config string AES-256-GCM encrypted ConfigPayload

Response (Error)

{
  "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:

{
  "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
{
  "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
{
  "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)

{
  "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)

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
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

import UIKit

let hwid = UIDevice.current.identifierForVendor?.uuidString ?? "unknown"

Alternative: Custom Serial

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

// 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

// 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

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)