Quick Start
Get Kiskis running in your iOS app in under 60 seconds.
1. Create an Account
Sign up at the Kiskis Dashboard and note your provisioning key.
2. Upload Your Config
curl -X POST https://api.kiskis.dev/v1/admin/config/upload \
-H "Authorization: Bearer sk_prod_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"version":"*","config":{"api_keys":{"stripe":"sk_live_..."}}}'
3. Add the Swift Package
In Xcode: File → Add Package Dependencies → enter:
https://github.com/kiskis/kiskis-ios-sdk
4. Fetch Config
import Kiskis
let kiskis = KiskisClient(
teamId: "YOUR_TEAM_ID",
bundleId: "com.your.app"
)
let config = try await kiskis.fetchConfig()
let stripeKey = config.string("api_keys.stripe")
That's it. The SDK handles App Attest, JWT tokens, Keychain caching, and background refresh automatically.
Core Concepts
How Kiskis Works
Kiskis solves the "bootstrap problem" — how does an app get API keys without embedding them in the binary?
- Developer uploads config (API keys, endpoints, feature flags) to the Kiskis vault via dashboard, CLI, or CI/CD.
- App launches and the SDK performs hardware attestation via Apple App Attest (Secure Enclave).
- Server verifies the attestation, confirming it's a genuine, unmodified app on a real device.
- Server returns the config, matched to the app's version number.
- SDK caches the config in the iOS Keychain, encrypted and hardware-bound.
Key Principles
- Nothing is embedded. Your app ships with only your Team ID and Bundle ID (public, non-secret data).
- Hardware proves identity. Apple's Secure Enclave generates a key pair that can never be extracted.
- Offline-first. Cached config loads instantly. Network fetches happen in the background.
- Version targeting. Different app versions can get different configs.
Security Layers
| Layer | Mechanism | Purpose |
| Transport | TLS 1.3 | Encrypt in transit |
| App Identity | App Attest (Secure Enclave) | Prove genuine device + app |
| Replay Prevention | signCount + nonce | Each request is unique |
| Authorization | JWT (15 min TTL) | Time-limited access |
| Path Obfuscation | Ed25519-signed S3 paths | Can't guess storage locations |
| Rate Limiting | API Gateway + WAF | Prevent abuse |
| Encryption at Rest | S3 SSE-KMS | Protect stored configs |
SDK Installation
Swift Package Manager (Recommended)
In Xcode, go to File → Add Package Dependencies and enter:
https://github.com/kiskis/kiskis-ios-sdk
Select the Kiskis library (core). Optionally add:
KiskisAnalytics — telemetry reporting (~1MB)
KiskisBinaryBlobs — large file downloads (~0.5MB)
Requirements
- iOS 14.0+ (App Attest requires Secure Enclave)
- Xcode 15+
- Swift 5.9+
App Attest Capability
In Xcode, go to your target → Signing & Capabilities → + Capability → App Attest.
Simulator note: App Attest uses sandbox mode in the simulator. The SDK auto-detects this and uses sandbox attestation endpoints. Full hardware attestation requires a physical device.
Usage Guide
Basic Usage
import Kiskis
let kiskis = KiskisClient(
teamId: "A1B2C3D4E5",
bundleId: "com.myapp.weather"
)
// Fetch all config
let config = try await kiskis.fetchConfig()
let stripeKey = config.string("api_keys.stripe")
let maxUpload = config.int("limits.max_upload_mb")
let darkMode = config.bool("features.dark_mode")
Non-Blocking Initialization
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions opts: ...) -> Bool {
Task {
do {
let config = try await KiskisClient.shared?.fetchConfig()
NotificationCenter.default.post(
name: .kiskisReady, object: config)
} catch {
handleOfflineState(error)
}
}
return true // Don't block main thread
}
}
Fallback Config (First Install Offline)
let kiskis = KiskisClient(
teamId: "A1B2C3D4E5",
bundleId: "com.myapp.weather",
fallbackConfig: Bundle.main.url(
forResource: "fallback_config",
withExtension: "json")
)
Security warning: Fallback configs are embedded in the binary and are NOT protected by attestation. Never put API keys in the fallback — only non-sensitive defaults (endpoints, feature flags).
Per-User Data
Store user-specific data tied to their iCloud identity. No sign-in required.
// The SDK auto-detects the iCloud recordID
// Data is stored at a hashed path — anonymous, cross-device
Offline & Caching
How Caching Works
- First run: SDK attests with Apple, fetches config, encrypts and stores in Keychain.
- Subsequent runs: Returns cached config instantly. Refreshes in background.
- Offline: Returns cached config. Sets
isStale flag if past TTL.
- Long offline (>7 days default): Cache expires. SDK returns error or fallback.
Cache Policy Configuration
let kiskis = KiskisClient(
teamId: "A1B2C3D4E5",
bundleId: "com.myapp.weather",
cachePolicy: .init(
maxStaleness: .days(7), // Trust cache for 7 days offline
backgroundRefresh: true, // Silent refresh when online
onStaleConfig: .warnAndUse // .warnAndUse | .failHard | .useSilently
)
)
Staleness Policies
| Policy | Behavior | Best For |
.warnAndUse | Return stale config with isStale = true | Most apps (default) |
.failHard | Throw error if stale | Financial apps |
.useSilently | Return stale config, hide staleness | Games, media apps |
Apple Outage Grace Mode
If Apple's attestation servers go down, the SDK extends the JWT TTL by 1 hour and serves cached config. Retries in background. Your app keeps working.
Zero-Knowledge Mode
In Zero-Knowledge mode, your config is encrypted on your machine before it ever touches our servers. We store and deliver opaque ciphertext. We literally cannot read your secrets.
How It Works
- CLI encrypts config locally using AES-256-GCM (key derived via HKDF)
- Encrypted blob is uploaded to Kiskis
- Server stores opaque ciphertext — cannot read or validate it
- SDK fetches ciphertext, decrypts locally with the vault key
CLI Usage
# Encrypt and upload in one step
kiskis upload --file secrets.json --key $KEY --ver "*" \
--encrypt --vault-pass "MyVaultKey"
# Or encrypt separately
kiskis encrypt --file secrets.json --vault-pass "MyVaultKey"
kiskis upload --file secrets.json.enc --key $KEY --ver "*"
SDK Usage
let kiskis = KiskisClient(
teamId: "A1B2C3D4E5",
bundleId: "com.myapp.weather",
zeroKnowledge: .enabled(key: "MyVaultKey")
)
// fetchConfig() automatically decrypts locally
let config = try await kiskis.fetchConfig()
Tradeoff: In ZK mode, the server can't preview, validate, or filter your config. Selective fetching (scope parameter) is not available — the entire blob must be decrypted client-side.
Dashboard
The Kiskis dashboard at dashboard.kiskis.dev provides:
- Setup: Enter your Team ID and Bundle ID, paste your provisioning key
- Config Editor: Upload and manage configs with version targeting
- Provisioning Keys: Create and revoke keys for CLI/CI access
Authentication is via Cognito (email + password, optional MFA).
CLI Tool
npx kiskis-cli --help
Commands
| Command | Description |
kiskis upload | Upload config for a version pattern |
kiskis encrypt | Encrypt a file locally (ZK mode) |
kiskis decrypt | Decrypt a file locally (testing) |
kiskis keys:create | Create a provisioning key |
kiskis keys:revoke | Revoke a provisioning key |
kiskis config:view | View the config manifest |
kiskis config:delete | Delete a version pattern or all configs |
Example
# Create a key
kiskis keys:create --team-id A1B2C3 --bundle-id com.my.app
# Upload default config
kiskis upload --file config.json --key sk_prod_... --ver "*"
# Upload v2-specific config
kiskis upload --file v2-config.json --key sk_prod_... --ver "2.*"
# View what's deployed
kiskis config:view --key sk_prod_...
CI/CD Integration
GitHub Actions
- name: Deploy Config to Kiskis
uses: kiskis/deploy-action@v1
with:
api-key: ${{ secrets.KISKIS_PROVISIONING_KEY }}
config-file: ./config/production.json
version: '*'
Zero-Knowledge in CI/CD
- name: Deploy Encrypted Config
uses: kiskis/deploy-action@v1
with:
api-key: ${{ secrets.KISKIS_PROVISIONING_KEY }}
config-file: ./config/secrets.json
version: '*'
zero-knowledge: 'true'
vault-pass: ${{ secrets.KISKIS_VAULT_PASS }}
Other CI Systems
Use the CLI directly:
npx kiskis-cli upload --file config.json --key $KISKIS_KEY --ver "*"
Version Targeting
Different app versions can receive different configs. This lets you roll out new API endpoints to v3.0+ while v2.x stays on the old ones.
Version Patterns
| Pattern | Matches | Example Use |
* | All versions | Default config |
2.* | Any 2.x.x | Major version override |
2.1.* | Any 2.1.x | Minor version override |
2.1.3 | Exactly 2.1.3 | Hotfix for specific build |
Matching Priority
When the app sends version 2.1.3, the server tries in order:
- Exact:
2.1.3
- Patch wildcard:
2.1.*
- Minor wildcard:
2.*
- Default:
*
First match wins.
Security Architecture
Threat Model
| Threat | Mitigation |
| Decompile app for keys | No keys in binary; attestation prevents unauthorized use |
| Fake app clones | App Attest verifies binary integrity + Secure Enclave |
| Bot/script calls API | No Secure Enclave = rejected |
| Replay attacks | signCount must increment; nonces are one-time |
| Probe S3 for data | Ed25519-signed hash paths; bucket denies ListBucket |
| MITM intercept | TLS 1.3; optional certificate pinning |
| Kiskis employee access | S3 Deny-all-except-Lambda; KMS encryption; CloudTrail audit |
S3 Path Obfuscation
Config is stored at cryptographically signed paths. Even if the bucket were exposed, an attacker sees only random hex strings with no way to map them to developers.
Input: "A1B2C3.com.myapp" (TeamID.BundleID)
Sign: Ed25519.sign(masterKey, input)
Path: SHA256(signature) → "a7f3b9c2e1d4..."
App Store Review Guide
What to Disclose
- In your App Review notes, mention that the app downloads configuration at runtime via Kiskis.
- The SDK includes a
PrivacyInfo.xcprivacy manifest. No user data is collected.
- The SDK uses only public Apple APIs (DeviceCheck framework).
Privacy Nutrition Labels
| Category | Collected? |
| Contact Info | No |
| Health & Fitness | No |
| Financial Info | No |
| Location | No |
| Identifiers | No (App Attest is anonymous) |
| Usage Data | No |
| Diagnostics | Optional (KiskisAnalytics module) |
Important: Do not use Kiskis to enable features that weren't reviewed by Apple (e.g., hidden in-app purchases). This will get your app rejected.
Testing Matrix
| Environment | Secure Enclave | Attestation | Config Source |
| Xcode Simulator | No (mocked) | Sandbox | Real server (sandbox) |
| Physical device (debug) | Yes | Sandbox | Real server (sandbox) |
| TestFlight | Yes | Production | Real server (production) |
| App Store | Yes | Production | Real server (production) |
Sandbox vs Production
The SDK auto-detects the environment:
#if DEBUG
KeySAS.environment = .sandbox
#else
KeySAS.environment = .production
#endif
The server accepts both sandbox and production attestation tokens, validating against the appropriate Apple certificate chain.
Delivery API
Base URL: https://vyy382jkp3.execute-api.us-west-1.amazonaws.com/v1
POST /auth/challenge
Get a one-time nonce for the attestation ceremony.
Response: { "nonce": "base64string" }
POST /auth/attest
Exchange App Attest attestation for JWT tokens.
Body: {
"attestationObject": "base64",
"keyId": "string",
"nonce": "string",
"teamId": "A1B2C3D4E5",
"bundleId": "com.my.app"
}
Response: {
"accessToken": "jwt",
"refreshToken": "jwt",
"expiresIn": 900
}
POST /auth/refresh
Refresh an expired access token.
Body: { "refreshToken": "jwt" }
Response: { "accessToken": "jwt", "refreshToken": "jwt", "expiresIn": 900 }
GET /config?version=2.1.3
Fetch config matched to the app version. Requires Authorization: Bearer {accessToken}.
Optional: &scope=api_keys.stripe,features for selective fetching.
Response: {
"config": { ... },
"matchedPattern": "2.*",
"requestedVersion": "2.1.3"
}
GET /blob/{key}
Get a presigned S3 URL for a binary blob. Expires in 5 minutes.
GET/PUT /user/data?user_id=xxx
Get or save per-user data identified by iCloud recordID.
Management API
All management endpoints require Authorization: Bearer {provisioning_key}.
POST /admin/config/upload
Body: { "version": "2.*", "config": { ... } }
Response: { "message": "...", "totalPatterns": 3, "allPatterns": [...] }
GET /admin/config
View the full config manifest (all version patterns).
DELETE /admin/config?version=2.*
Delete a specific version pattern. Omit version to delete all.
POST /admin/keys/create
Body: { "teamId": "...", "bundleId": "...", "keyName": "..." }
Response: { "key": "sk_prod_...", "message": "Save this key..." }
POST /admin/keys/revoke
Body: { "key": "sk_prod_..." }
POST /admin/kill-switch
Body: { "versions": ["2.1.3"], "enabled": false, "reason": "..." }
POST /admin/emergency-revoke
Body: { "version": "*", "force_refresh_ttl": 300 }
POST /admin/blob/upload
Body: { "blob_key": "model.bin", "data_base64": "...", "content_type": "..." }