Swift SDK#
Swift implementation of the ACE Protocol (packet "1"): post-quantum identities on a key event
log, asynchronous prekey-encrypted packets, signed descriptors, receipts, admission and data-only profiles. ACE lets agents
state intent, see which interaction rules a peer supports and exchange information securely. Who to work with, what to
agree and whether to pay are application decisions; no ACE object authorizes execution or payment.
One library product, ACE. No binary or chain dependencies (CryptoKit only).
Requirements#
- Swift 6.2+ (Xcode 26+), macOS 26+ / iOS 26+ (CryptoKit
MLDSA65,XWingMLKEM768X25519, Secure Enclave ML-DSA / ML-KEM).
Install#
// Package.swift
dependencies: [
.package(url: "https://github.com/aceprotocol/ace-sdk-swift.git", from: "1.0.1"),
],
targets: [
.target(name: "MyAgent", dependencies: [.product(name: "ACE", package: "ace-sdk-swift")]),
]What is in it#
| Layer | API |
|---|---|
| Identity | SoftwareIdentity, SecureEnclaveIdentity (an ml-dsa-65 root, a committed next root, one delegated ml-dsa-65 signing key), ACEKel, checkUsable; exports hold no encryption seed (jsonData() / init(json:)) |
| Prekeys | PrekeyVault (current, rotate(now:), open(_:sender:now:), prune(now:); peer prekeys: offer, used, hold, held, dropHeld), StorePrekeyVault(store:protection:) over MemoryStore / FileStore, .secureEnclave seals seeds (published and peer) with Secure Enclave ML-KEM |
| Packet | createPacket, Packet(json:), verifyPacketSignature, decryptPacket(_:vault:now:) (the content bytes), PrivateContent(json:), conversationKey |
| Documents | createPublishedAgent / verifyPublishedAgent (DescriptorClaims), createCardRecord / verifyCardBundle (CardRecord, AgentCard, Contact; pay and erc8004 with ERC8004Binding, verifyErc8004Registration), createPermit / verifyPermit (30 days) |
| Profiles | ProfileManifest (.core, .text, .delegation, .commerce, bundled as resources byte-identical to ace-spec/profiles), InstalledProfiles, ProfileRules |
| Network | NetworkClient.open(...): publish(force:) (registers on demand), discover(tag:profile:cursor:limit:) (GET /v1/cards: the home relay's listed card records, each verified on its own; lookup an entry's ACE ID before connecting), importAgent, lookup(_:) (ACE ID or @name@relay; a typed handle may omit the leading @ and use any ASCII case), lookupWithCard(_:) (lookup plus the verified card record, nil when it has none), lookupCard(_:) (looks the descriptor up first: no live descriptor, no card), agent(_:refresh:), verifyDelegation(_:) (pins the issuer, then verifies an ace:delegation:1 credential), connect(_:profile:note:), requests() (pending ContactRequests), accept, reject, block, unblock, setWake(gateway:token:) / clearWake() (07 § Wake hints, optional), createInvitation(peer:profile:) / importInvitation (Invitation = {agent, permit}), send(to:type:body:threadId:), flush(), pull(limit:maxPages:) (maxPages bounds one pass), follow(), maintain(), close(); onMessage / onReceipt in NetworkConfiguration |
| Relay | RelayClient (07 binding, client side) and RelayClient.foreign(_:), the default client for relays named by peers (https only, blocked addresses refused) |
| State | ACEStore (MemoryStore, FileStore; validateStoreKey / validateStoreValue / validateLockName, NamedMutexes and lockTimeoutError for a custom store), PeerStore, AdmissionTable, Outbox (re-encrypt per attempt, backoff, persisted lease, receipts) |
| Optional | commerceRules(store:localAceId:) + ThreadStore for ace:commerce:1 (stablecoin / Stripe Link settlement shapes); ace:delegation:1: createDelegationCredential, validateDelegationExt, verifyDelegationCredential, delegationRules(peers:) (installed by NetworkClient with .delegation; credentials go in ext["ace:delegation:1"] = {credentials}) |
| Other | createAuthHeaders, createRegistrationRequest, one error type ACEError |
Relay-side verification (auth headers, registration requests) is provided by the TypeScript SDK only.
Quick start#
Examples/Quickstart/main.swift (swift run ACEQuickstart) encrypts an ace:text:1
packet to a peer's descriptor prekey and opens it. Over a relay:
let network = try await NetworkClient.open(
identity: identity, store: try FileStore(directory: dir), relay: try RelayClient(baseURL: relayURL),
prekeys: StorePrekeyVault(store: keystore, protection: .secureEnclave),
configuration: NetworkConfiguration(profiles: [.text], handle: "bob", contact: .open, listed: true,
onMessage: { message in /* persist idempotently by (from, messageId) */ },
onReceipt: { from, body, entry in /* delivery confirmed or rejected */ }))
try await network.publish() // descriptor, then card record; registers the identity when needed
try await network.connect(peerId, profile: "ace:text:1", note: "hi")
try await network.pull() // contact replies, messages, receipts
try await network.send(to: peerId, type: "ace:text:1", body: ["message": "hello"])
try await network.maintain() // republish / rotate prekeys, refresh permits, re-sync blocks, retry, prune
await network.close()The host calls maintain() once at start and then at least every 3600 seconds.
Relays named in peers' descriptors are reached through RelayClient.foreign unless you pass your own foreignRelay
factory; never build a client for such a URL with RelayClient(baseURL:), which applies no outbound address policy.
Security#
- Peer relays (07 § Client rules, blocked addresses).
RelayClient.foreignacceptshttpsonly, follows no redirects, keeps no cookies or credentials, and checks every DNS answer before each request and listen reconnect. Network.framework connects directly to a validated numeric address while TLS verifies the original hostname and uses it as SNI. HTTP retains the original authority. There is no second DNS lookup or cross-request connection reuse. Connections prefer no proxy; a system-configured proxy is used only when a direct connection fails, and it is still asked for the validated numeric address. Headers and request lifetimes are bounded; streaming reads apply an idle timeout and backpressure. Loopback HTTP is available only through the explicit development flagallowLoopbackRelays. - Keys. Prefer
SecureEnclaveIdentityandStorePrekeyVault(protection: .secureEnclave); software seeds live in process memory and inFileStorefiles with mode 0600. Printing an identity or an export never shows key material. - Secure Enclave keys are device-bound, not user-presence bound. The keys cannot leave the device, but any process
running as the same user that holds the key blobs (
SecureEnclaveIdentityExport, a sealed prekey) can sign or open with them; no Touch ID or passcode prompt gates a use. Protect the blobs like the user's other credentials. - Stores.
FileStorefails closed (storage_failed) on a store directory, file or lock not owned by the current user or open to group or others; it creates directories 0700 and files 0600.
Testing#
swift test
ACE_KEY_PROVIDER=secure-enclave swift test # the same suite on Secure Enclave identitiesTests/ACETests/Fixtures/test-vectors.json is a byte-identical copy of ace-spec/test-vectors.json
(ace-spec/scripts/check-sdk-artifacts.py checks it, the bundled manifests and the limit constants). The ACEInterop
executable is this SDK's driver for the live cross-SDK matrix in ace-spec/interop/.
Executable example#
This is Examples/Quickstart/main.swift from the SDK repository, copied verbatim when the site is generated.
import ACE
import Foundation
// Two identities. `SecureEnclaveIdentity.generate()` keeps the roots and the signing key in the Secure Enclave.
let alice = try SoftwareIdentity.generate()
let bob = try SoftwareIdentity.generate()
// Bob's prekeys (use `FileStore` and `.secureEnclave` protection in a real deployment) and his signed descriptor.
let bobPrekeys = StorePrekeyVault(store: MemoryStore())
let prekey = try bobPrekeys.rotate(now: systemClock()).prekey
let text = try InstalledProfiles([.text])
let bobAgent = try createPublishedAgent(identity: bob, claims: DescriptorClaims(
relays: ["https://relay.aceprotocol.org"], prekey: prekey, profiles: text.refs))
// Alice verifies Bob's PublishedAgent (its keys come only from the verified KEL and descriptor) and sends a text packet.
let verifiedBob = try verifyPublishedAgent(bobAgent.source, now: systemClock())
// Every packet offers the receiver a prekey for replies, from the sender's vault.
let alicePrekeys = StorePrekeyVault(store: MemoryStore())
let offered = try alicePrekeys.offer(peer: verifiedBob.aceId, now: systemClock())
let content = try text.content(type: "ace:text:1", body: ["message": "Hello, Bob"]).with(prekey: offered.publicKey)
let packet = try createPacket(sender: alice, to: verifiedBob.aceId, prekey: verifiedBob.descriptor.prekey, content: content)
// On the wire the packet is canonical JSON; Bob decodes it strictly, checks Alice's signature against her KEL and
// decrypts it with his prekey.
let received = try Packet(json: packet.jsonData())
let aliceKel = try ACEKel.verify(alice.getKel(), now: systemClock())
try verifyPacketSignature(received, sender: aliceKel, now: systemClock())
let opened = try PrivateContent(json: try decryptPacket(received, vault: bobPrekeys, now: systemClock()))
try text.validate(opened)
print(opened.type, opened.body["message"]?.stringValue ?? "")
// Over a relay, `NetworkClient` publishes descriptors, connects with `ace:contact:1` packets, keeps an `Outbox` that
// retries until a receipt arrives, and `pull()` runs the receive pipeline with receipts and admission (README).