Apple SDK · 1.0.0

Apple SDK

The client for Keywarden on iOS and macOS: App Store purchases, license keys, spend-tier gifts and metered trials behind one signed bundle that verifies offline. Swift 5.9+, iOS 15+ / macOS 12+, and the Keywarden library has no third-party dependencies — Foundation, CryptoKit and Security only.

Install

The package ships three libraries. Take Keywarden always; add KeywardenStoreKit if you sell through the App Store, and KeywardenTesting in your test target only.

// Package.swift
dependencies: [
    .package(url: "https://github.com/dikeckaan/keywarden-apple.git", from: "1.0.0"),
],
targets: [
    .target(name: "App", dependencies: [
        .product(name: "Keywarden", package: "keywarden-apple"),
        .product(name: "KeywardenStoreKit", package: "keywarden-apple"),
    ]),
    .testTarget(name: "AppTests", dependencies: [
        .product(name: "KeywardenTesting", package: "keywarden-apple"),
    ]),
]
LibraryWhat it isDepends on
KeywardenClient, bundle verification, storage, transportFoundation · CryptoKit · Security
KeywardenStoreKitStoreKit 2 bridge (launch scan + Transaction.updates)Keywarden · StoreKit
KeywardenTestingFakeKeywarden, MockTransportKeywarden

Configure

Configure once, as early as your app has a run loop. Both values come from the app's Setup page in the dashboard: the public key (pk_live_…) and the signing key's SPKI, keyed by its kid.

import Keywarden

let configuration = try Configuration(
    apiKey: "pk_live_…",
    baseURL: URL(string: "https://api.keywarden.dev")!,
    publicKeys: ["k1": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE…"],
    appSlug: "your-app",
    deviceInfo: .none
)
let client = Keywarden.configure(configuration)

Pinning is not optional. Configuration throws rather than degrading silently:

ConditionErrorWhy it is fatal
no pinned key.noPinnedKeysWithout pinning, any server — or anyone between you and it — could mint entitlements.
plain http (not localhost).insecureBaseURLThe API key travels on every request.
existingInstallId outside 8–128 chars.invalidInstallIdShort ids are guessable; installId is a capability.

During a key rotation pass both keys — the active one and the one still inside the overlap window — so bundles signed by either verify: publicKeys: ["k2": newSpki, "k1": oldSpki]. The Setup page prints exactly the set you should ship.

deviceInfo controls what leaves the device: .none sends only the installId, .app adds the app version, .full adds the OS version and hardware model. Nothing else is ever collected.

Sync & entitlement state

Every read is synchronous, offline-safe and never blocks. Call them from SwiftUI bodies freely.

if client.has("pro_unlock") { unlockPro() }

client.entitlements()            // Set<String>
client.source("pro_unlock")      // .store | .license | .gift
client.expiresAt("pro_unlock")   // Date?
client.installId()               // this install's secret id

Network calls are async throws. Sync on launch and when the app returns to the foreground:

do {
    let verdict = try await client.sync()        // .granted
} catch let error as KeywardenError {
    // .unknown — a network error is NOT a rejection. Nothing is dropped.
    print(error.code, error.verdict)
}

Observe changes with a listener (called on the main thread) and re-render:

client.addListener { entitlements in
    self.isPro = entitlements.contains("pro_unlock")
}

Verdicts: GRANTED, REJECTED, UNKNOWN

The whole client obeys one rule: only a rejection takes an entitlement away. Anything the server could not decide leaves local state exactly as it was — a paying user is never locked out by a flaky network, a rate limit or a capacity error.

Server answerVerdictEffect on local entitlements
200 with a bundle.grantedReplaced by the signed bundle.
200 without a bundle.grantedUnchanged — nothing changed server-side.
402 PURCHASE_INVALID · VOIDED.rejectedDropped on the next bundle.
410 LICENSE_REVOKED · LICENSE_EXPIRED · LICENSE_SUSPENDED.rejectedDropped on the next bundle.
409 NO_SEATS_AVAILABLE · DEVICE_LIMIT_REACHED.unknownUnchanged. Show a message; do not lock anyone out.
400 · 401 · 429 · 500 · 502 · no network.unknownUnchanged.

hasVerifiedBundle() answers a different question: has this install ever been handed authoritative state? A verified bundle with an empty entitlement list answers true here and false from has. Use it only when you must retire a legacy licence store exactly once Keywarden becomes the authority — never as an entitlement check.

StoreKit 2 purchases

One line wires StoreKit to Keywarden. It scans Transaction.currentEntitlements and unfinished transactions at launch, then listens to Transaction.updates, sending each signed transaction to POST /v1/store/verify.

import KeywardenStoreKit

let sync = StoreKitSync(client: client, source: StoreKitTransactionSource())
sync.start()

Three behaviours are worth knowing because they are deliberate:

BehaviourWhy
finish() is called only after the server accepts the transaction.Finishing on a network error would destroy a purchase the user paid for; an unfinished transaction is replayed on the next launch.
A transaction with a revocationDate triggers revokeLocal before the server call.Even with no network, a refunded entitlement stops working immediately. revokeLocal can only ever take rights away.
The SDK never sends ownedStoreProducts on Apple.currentEntitlements returns empty on a device signed out of the App Store, and there is no API to tell that apart from "the query failed" — claiming the list was complete would lock out a paying user. On Apple the refund source is the server: App Store Server Notifications V2, the JWS revocationDate, and a live check against the App Store Server API.

Server-side setup (the In-App Purchase key and the notification URL) is on the Apple App Store setup page. Until a connection exists, /v1/store/verify answers 400 VALIDATION — an operator mistake is never reported as 402, because the SDK would read that as a rejection and drop entitlements.

License keys in the app

Selling outside the store as well? Activate a KW-… key against this install:

do {
    _ = try await client.activate(licenseKey: "KW-XXXX-XXXX-XXXX-XXXX")
} catch let e as KeywardenError where e.verdict == .rejected {
    // 410 — revoked, expired or suspended. Tell the user the key is not valid.
} catch {
    // 409 NO_SEATS_AVAILABLE or a network error: unknown, nothing is lost.
}

let status = try await client.validate(licenseKey: key)   // query, not a claim
let released = try await client.deactivate(licenseKey: key)

activate returns a fresh bundle on success; entitlements are dropped by the next signed bundle, never by the client deciding on its own.

Metered features & trials

"Try it three times" counters live on the server, not in a file the user can reset.

let state = try await client.peek(meter: "flow_run")
if state.allowed {
    let after = try await client.consume(meter: "flow_run")
    print(after.left, "runs left of", after.limit)
}

A failed consume is .unknown: spend nothing and let the user through, or retry — never treat it as "out of runs".

Testing

KeywardenTesting gives you a double with the same reading surface, so app code is tested without a server, a keychain or a signature.

import KeywardenTesting

let fake = FakeKeywarden(entitlements: ["pro_unlock"])
#expect(fake.has("pro_unlock"))

fake.nextVerdict = .rejected
_ = try await fake.sync()
#expect(!fake.has("pro_unlock"))            // a rejection drops rights

fake.nextError = KeywardenError(code: "NETWORK", verdict: .unknown, message: "offline")
fake.grant("pro_unlock")
_ = try? await fake.sync()
#expect(fake.has("pro_unlock"))             // unknown drops nothing

Need the real client against scripted responses? MockTransport plugs into KeywardenClient(configuration:storage:transport:clock:) with InMemoryStorage and a fixed Clock, so expiry and clock-rollback paths are testable.

Security checklist

  • Pin the signing key. Ship the SPKI in the binary. A bundle whose kid is not pinned is treated as forged — no other key is tried.
  • Treat installId as a secret. With your pk_ it is enough to act as this device. The SDK keeps it in the keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — it does not travel to backups or a new device — and never prints it in any description or dump output.
  • Do not put it in appAccountToken. That value goes to Apple and appears in plain text in every JWS and notification. The SDK neither sets nor reads it.
  • Never gate on the verdict alone. Gate on has(). A .granted answer with no bundle means "nothing changed", not "the user is entitled".
  • Keep the app slug pinned (appSlug) unless one binary genuinely serves several apps: it stops a bundle issued for another app from being accepted.

Differences from Android

The two SDKs share their semantics on purpose: the same storage keys, the same three-state model, the same source precedence (store > license > gift), the same clock-rollback floor, and both are tested against the same fixture tokens produced by the server's own signing code. What differs is shaped by the platform:

AndroidApple
Callbacks (sync(SyncCallback))async throws
SharedPreferencesKeychain (kSecClassGenericPassword)
Play Billing sends ownedStoreProductsNot sent — see StoreKit 2 purchases
Play Integrity supportedNo equivalent; the signed StoreKit JWS is the proof

A rule added to one SDK is added to the other. If you find a behaviour that differs and is not in this table, it is a bug — please report it.