The Osaurus Identity system gives every participant — human, agent, and device — a cryptographic address. All actions are signed and verifiable, enabling trust without a central authority at runtime.
This document covers the theory behind the design, the address hierarchy, key derivation, request signing, access keys, and the security properties that follow.
- Theory and Motivation
- Address Hierarchy
- Key Derivation
- Two-Layer Request Signing
- Access Keys (osk-v1)
- Whitelist System
- Revocation
- Master Key Backup and Recovery
- Identity Health and Drift
- Per-Agent Key Management
- Internal vs External Communication
- Security Properties
- File Reference
AI agents that communicate — internally within an application, or externally with other services and agents — need a trust mechanism. Traditional approaches rely on centralized session tokens or API keys that a server issues and validates. This creates a single point of failure and requires the central authority to be online and reachable for every interaction.
Osaurus takes a different approach: address-based identity. Every participant derives a cryptographic keypair and is identified by the address of its public key. When an agent signs a message, any verifier can confirm the signature came from that address without contacting a server. Authority flows from a human-controlled root key down to agents, and from there to devices — forming a verifiable chain of trust.
Design goals:
- Self-identifying — Every agent carries its own address. No lookup table or registry needed.
- Verifiable — Signatures can be checked by anyone holding the public address. No callbacks to a central authority.
- Hierarchical — Authority flows from human (master) to agent to device, with clear delegation boundaries.
- Offline-capable — Agents can prove their identity without network access to an identity server.
- Revocable — Compromised keys can be revoked at any level without replacing the entire identity tree.
The identity system has three tiers, each serving a distinct role:
Master Address (Human) ← iCloud Keychain / 24-word phrase; same on every device
├── Device A (device ID a1b2c3d4)
│ ├── Agent Address v2 (scope a1b2c3d4, index 0)
│ ├── Agent Address v2 (scope a1b2c3d4, index 1)
│ └── ...
├── Device B (device ID c3d4e5f6)
│ ├── Agent Address v2 (scope c3d4e5f6, index 0)
│ └── ...
└── Legacy Agent Address v1 (index n) ← minted before device scoping; master-global
The master is shared by every device that holds the same identity. Each
device has its own hardware-bound device ID, and every agent address minted on
that device is derived from (master, deviceID, index). Two Macs (or a Mac and
a future iOS client) restored from the same phrase therefore mint disjoint
agent addresses even when both allocate index 0 — there is no cross-device
index coordination, and none is needed. Ownership is still provable from the
master alone: any device holding the master can re-derive and sign for any of
its agents given the stored (deviceScope, index) path.
The human's root identity. All authority in the system flows from this address.
- Curve: secp256k1
- Storage: iCloud Keychain (syncs across Apple devices)
- Access: Requires biometric authentication (Face ID / Touch ID)
- Format: Checksummed hex address (EIP-55 style), e.g.
0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18
The master key is a 32-byte random secret generated via SecRandomCopyBytes. It is stored once in the Keychain and never exported during normal operation. The address is derived from the corresponding secp256k1 public key via Keccak-256 hashing.
Overwrite protection. MasterKey.generate(allowReplace:) defaults to allowReplace: false and throws OsaurusIdentityError.masterAlreadyExists if a master is already present. OsaurusIdentity.setup() short-circuits when an identity exists and returns the existing master without minting a new one. Replacing the master is only allowed via the explicit "Reset Identity" or "Recover from phrase" flows in the Identity view (both of which pass allowReplace: true). This guards against the silent-overwrite class of bug where a re-run of onboarding would otherwise strand every persisted agent address and access key.
Each agent in Osaurus gets a deterministic child key derived from the master key. Agents can sign messages on their own behalf, but their authority always traces back to the master address.
- Derivation: HMAC-SHA512 with domain separation; v2 additionally binds the minting device's ID
- Storage: Agent keys are never stored — they are re-derived on demand from the master key
- Association: Each agent's
agentIndex,agentDeviceScope(nil for legacy v1), andagentAddressare persisted on theAgentmodel.Agent.agentKeyPathbundles the first two into anAgentKeyPaththat every derivation/signing site takes, so v1 and v2 agents are handled by one code path.
Agent addresses enable per-agent scoping: an access key signed by an agent can only authorize actions for that specific agent, not the entire identity.
Lifecycle. Three operations mutate an agent's derived identity, all on AgentManager and all driven from the Identity view:
| Operation | Effect |
|---|---|
assignAddress(to:) |
Allocates the next unused index under this device's scope (v2) and persists the derived address plus agentDeviceScope. No-op if the agent already has one. |
rotateAddress(of:) |
Allocates a fresh unused v2 path, re-derives a new address, and revokes every active osk-v1 key whose audience matched the previous address. Paths are never reused — old addresses may still be referenced by external clients holding tokens. Rotating a legacy v1 agent moves it to v2. |
revokeAddress(of:) |
Clears the agent's address, index, and device scope, and revokes every active osk-v1 key scoped to it. The agent itself stays around (prompt, settings, etc.) but loses signing authority until a fresh address is assigned. |
Existing v1 agents keep their addresses: nothing about pairings, shares, or access keys changes on upgrade. New and rotated addresses are always v2.
A hardware-bound identity that proves which physical device is making a request.
- Hardware: Apple App Attest (Secure Enclave P-256 key)
- Format: 8-character hex string derived from the attestation key ID
- Fallback: Software-generated random ID when App Attest is unavailable (development builds)
The device ID adds a second authentication factor: even if someone obtains a valid identity signature, they cannot forge the device assertion without physical access to the Secure Enclave.
The device ID is a device fact, not an identity fact. It is attested
independently of master creation via OsaurusIdentity.ensureDeviceAttested()
(→ DeviceKey.ensureAttested()), which returns the existing ID or attests a
new one but never replaces one that exists. setup() calls it on both the
fresh and existing-master branches, loadExistingIdentity() and
restore(words:) call it too, so a device that received the master through
iCloud Keychain sync or a phrase restore is attested on first load instead of
failing with deviceNotAttested. Reset Identity and Recover from phrase
replace the master but keep the device ID.
32 random bytes (SecRandomCopyBytes)
→ secp256k1 private key
→ uncompressed public key (drop 0x04 prefix)
→ Keccak-256 hash
→ last 20 bytes
→ checksummed hex address (EIP-55)
The master key is stored in iCloud Keychain with kSecAttrAccessibleWhenUnlocked. iCloud sync is attempted first; if unavailable, the key is stored device-only with kSecAttrAccessibleWhenUnlockedThisDeviceOnly.
Shared Keychain access group — code-ready, not shipped. iCloud Keychain only hands a synced item to another app in the same access group, so a same-team iOS client can receive the master without the phrase only if both apps write into a shared group, <TeamID>.ai.osaurus.identity. OsaurusKeychainGroup resolves that group from the OsaurusKeychainAccessGroup Info.plist key, and MasterKey / MasterMnemonicStore dual-write and mirror as described below whenever it resolves.
Why it is off: keychain-access-groups is a profile-managed entitlement on macOS. The Developer ID release build ships without a provisioning profile, and adding the key made AMFI refuse to spawn the app — that bricked 0.19.3 (#1288, reverted in #1296, which also added the RuntimePolicySourceTests guard that fails CI if the key reappears in osaurus.entitlements, and the verify_launch.sh spawn gate in the release workflow). Neither the entitlement nor the Info.plist key is in App/osaurus, so OsaurusKeychainGroup.shared is nil in every shipped build and the Keychain layout is unchanged: one synced item in the app's default group. Enabling the group is a release-pipeline decision — embed a provisioning profile for the Developer ID build, or pick a mechanism macOS accepts without one — and is tracked as BLOCKED rather than partially shipped. Until then, a phone's only bootstrap path is the 24-word phrase (see MOBILE_PROTOCOL.md § 2.2).
When the group is enabled, the layout below applies.
The layout is deliberately two copies of the same bytes, never one:
- Dual-write (
MasterKey.addGenericPassword, order pinned byOsaurusKeychainGroup.writeAttemptGroups): the app's default per-app group is written first and unconditionally — sync, then device-only — because that is the only group a Mac still on a build without the entitlement can read. The shared group is then written as an additional copy.errSecMissingEntitlementon the shared group (dev signing, SwiftPM tests, the CLI) just leaves that copy out. - Add-only mirror (
MasterKey.mirrorGenericPassword, decision inOsaurusKeychainGroup.mirrorPlan): once per process after a successful read, list every item for the(service, account); if a synced copy exists but none in the shared group, add one there; if every copy is in the shared group (minted by a client that only wrote there), add one in the default group. Each added copy is read back through a group-precise query and removed again on mismatch. Nothing pre-existing is ever deleted. An iCloud Keychain delete propagates to every device, so a "move" would make a second Mac on the previous build watch its only visible master disappear, report "no identity", and mint a second master on Create — two masters, one account. A device-only master (data-protection default group or the file-based login keychain, which has no access groups) is left where it is: it was never going to reach another device. - Read and delete queries never name a group and use
kSecAttrSynchronizableAny, so a read matches either copy (same bytes, so order does not matter) and Reset Identity / Recover from phrase clear both before re-writing both.
Consequence for a mixed-version fleet: a Mac on an entitled build and a Mac on a build without the group keep unlocking the same master, and a Reset or Recover on either is seen by the other. Dropping the default-group copy would require every device to be on an entitled build, which a client cannot observe; the duplicate costs nothing, so there is no plan to remove it.
Two derivations exist. Which one applies to an agent is decided by its stored
AgentKeyPath (Agent.agentKeyPath): deviceScope == nil → v1, otherwise v2.
v2 (device-scoped, current — everything minted or rotated now):
HMAC-SHA512(
key: masterKey, // 32 bytes
data: "osaurus-agent-v2" || utf8(deviceScope) || 0x00 || bigEndian(index)
)
→ first 32 bytes of HMAC output
→ same address derivation as master key
deviceScope is the minting device's device ID (see below). The 0x00
terminator makes the variable-length scope unambiguous against the index.
Different devices under the same master produce disjoint address spaces, so
the same index on two devices is two different agents with two different
addresses.
v1 (legacy, master-global — agents minted before device scoping):
HMAC-SHA512(
key: masterKey, // 32 bytes
data: "osaurus-agent-v1" || bigEndian(index) // domain + 4-byte index
)
→ first 32 bytes of HMAC output
→ same address derivation as master key
The distinct domain prefixes (osaurus-agent-v1 / osaurus-agent-v2) prevent cross-protocol and cross-version key reuse: a v2 address at index 0 is unrelated to the v1 address at index 0. The big-endian index encoding ensures a canonical byte representation across platforms. Each unique path produces a completely independent keypair.
Connect identity (one per Mac, for the paired phone):
HMAC-SHA512(
key: masterKey,
data: "osaurus-connect-v1" || utf8(deviceId) || 0x00 // empty deviceId without one
)
→ first 32 bytes of HMAC output
→ same address derivation as master key
Not an agent key and not stored on any agent: it exists so a phone paired to a Mac with no custom agents still has an address to pin, since the built-in Default agent has none. Only POST /secure/session accepts it; the relay, Bonjour, access keys and invites never see it. Its own domain keeps it apart from every agent key at every index. See MOBILE_PROTOCOL.md §11.3 and MobileConnectIdentity.swift.
Agent keys are never persisted. They are re-derived from the master key whenever a signature is needed, which requires biometric authentication to access the master key. The derived agentAddress is persisted on the Agent model so it can be displayed without triggering biometric prompts.
- Hardware path:
DCAppAttestService.generateKey()creates a P-256 key in the Secure Enclave. The key ID is hashed with SHA-256 and truncated to 4 bytes (8 hex characters) for the device ID. - Software fallback: 4 random bytes via
SecRandomCopyBytes, stored inUserDefaultsfor stability across app launches.
Every authenticated API request carries a two-layer signed token. This binds each request to both a cryptographic identity and a physical device.
header.payload.accountSignature.deviceAssertion
Four base64url-encoded segments joined by .:
| Segment | Encoding | Content |
|---|---|---|
| Header | base64url(JSON) | Algorithm, type, version |
| Payload | base64url(JSON) | Claims (see below) |
| Identity Signature | hex | secp256k1 recoverable signature (65 bytes) |
| Device Assertion | base64url | App Attest assertion (or empty for software fallback) |
{
"alg": "es256k+apple-attest",
"typ": "osaurus-id",
"ver": 5
}| Field | Type | Description |
|---|---|---|
iss |
string | Issuer address (master or agent) |
dev |
string | Device ID (8-char hex) |
cnt |
uint64 | Monotonic counter (anti-replay) |
iat |
int | Issued-at timestamp (Unix seconds) |
exp |
int | Expiration timestamp (Unix seconds, typically iat + 60) |
aud |
string | Audience (target service hostname) |
act |
string | Action being authorized (e.g. "GET /v1/models") |
par |
string? | Parent address (for agent-issued tokens, the master address) |
idx |
uint32? | Agent index (for agent-issued tokens) |
- Encode payload as JSON
- Layer 1 — Identity signature: Domain-separated secp256k1 signing
- Envelope:
\x19Osaurus Signed Message:\n<length><payload> - Hash: Keccak-256 of the envelope
- Sign: secp256k1 with recovery (produces 65 bytes: r ‖ s ‖ v)
- Envelope:
- Layer 2 — Device assertion: App Attest assertion over SHA-256 of the payload
- Assemble:
base64url(header).base64url(payload).hex(accountSig).base64url(deviceAssertion)
The domain prefix Osaurus Signed Message prevents signed payloads from being replayed in other protocols that use the same curve.
Access keys are portable, long-lived tokens for external authentication. They allow tools, MCP clients, and remote agents to authenticate against Osaurus without biometric access to the device.
osk-v1.<base64url-encoded-payload>.<hex-encoded-signature>
Three parts separated by .:
- Prefix:
osk-v1(identifies the token format and version) - Payload: Base64url-encoded canonical JSON
- Signature: Hex-encoded 65-byte secp256k1 recoverable signature
| Field | Type | Description |
|---|---|---|
aud |
OsaurusID | Audience address (who this key is for) |
cnt |
uint64 | Counter value at creation time |
exp |
int? | Expiration timestamp (null = never expires) |
iat |
int | Issued-at timestamp |
iss |
OsaurusID | Issuer address (who signed this key) |
lbl |
string? | Human-readable label |
nonce |
string | Unique identifier for revocation |
Fields are sorted alphabetically for canonical JSON encoding (ensuring consistent signature verification).
- Master-scoped: Signed by the master key.
issandaudare both the master address. Grants access to all agents. - Agent-scoped: Signed by a derived agent key.
issandaudare both the agent address. Grants access only to that specific agent.
The /pair Bonjour flow always issues agent-scoped keys (agentIndex taken from the approved agent). Keys generated manually from the Settings UI may be either, depending on what the user selects.
| Option | Duration |
|---|---|
30d |
30 days |
90d |
90 days (default for /pair) |
1y |
1 year |
never |
No expiration (only when the user explicitly opts in via the pairing dialog's "Remember this device permanently" toggle) |
When a request arrives with an osk-v1 token:
- Parse the three segments (prefix, payload, signature)
- Decode the base64url payload into
AccessKeyPayload - Recover the signer address via
ecrecoverwithOsaurus Signed Accessdomain prefix - Verify issuer — recovered address must match
payload.iss - Check audience —
payload.audmust match the master address or the derived address of any current agent (the validator is built fromAgentIdentityRegistry, so keys paired against any agent are accepted at the gate) - Check whitelist —
payload.issmust be in the effective whitelist - Check revocation — not individually revoked (address + nonce) and not bulk-revoked (counter threshold)
- Check expiration —
payload.expmust be in the future (if set)
Only metadata is stored after key creation (label, prefix, nonce, counter, addresses, dates). The full key string is shown once and never persisted.
Route-level scoping. Passing the gate is necessary but not sufficient for agent routes: when a request authenticated with an agent-scoped key reaches /agents/{id}/run or /agents/{id}/dispatch, the handler additionally checks that the key's validated aud matches the target agent's derived address and rejects cross-agent access with 403 agent_scope_denied. Master-scoped keys are unrestricted.
Validator freshness. The server's lock-free validator snapshot is invalidated by a global epoch counter (APIKeyValidatorEpoch) that is bumped on key generation, revocation, deletion, whitelist edits, and agent list changes. The next request after any of those events rebuilds the validator, so revocations and first-time pairings take effect without a server restart.
The whitelist controls which addresses are authorized to issue access keys. It operates at two levels:
Addresses in the master whitelist can issue keys for any agent. This is where you'd add trusted external addresses.
Additional addresses can be authorized for specific agents only. These are additive — they extend the master whitelist, not replace it.
The effective whitelist for a given agent is computed as:
effective = masterWhitelist ∪ agentWhitelist[agent] ∪ {agentAddress, masterAddress}
The agent's own address and the master address are always implicitly included.
Whitelist data is persisted in macOS Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly), keyed by com.osaurus.whitelist.
Access keys can be revoked through two mechanisms:
Revoke a specific key by its (address, nonce) pair. The composite key address:nonce is added to the revocation set.
Revoke all keys from an address with counter values at or below a threshold. This is implemented as a counter threshold per address — any key with cnt <= threshold is considered revoked.
When checking revocation:
isRevoked = revokedKeys.contains(address:nonce)
|| (counterThresholds[address] >= cnt)
Revocation data is persisted in macOS Keychain (kSecAttrAccessibleWhenUnlockedThisDeviceOnly), keyed by com.osaurus.revocations.
The identity system has two independent recovery artifacts. They serve different purposes and are not interchangeable.
A standard BIP39 24-word mnemonic encoding the 32-byte master key. This is the only artifact that can rebuild the local secp256k1 master if the iCloud Keychain entry is ever lost or replaced.
1. flat 2. depend 3. bright 4. dose ...
24. bargain
- Algorithm: BIP39 §3. 256 bits of entropy plus the high 8 bits of
SHA-256(entropy)as a checksum (264 bits total = 24 × 11), split into 24 11-bit big-endian indices into the canonical 2048-word English wordlist. - Encoding: Implemented in
Identity/MasterKeyMnemonic.swiftwith no external SwiftPM dependency. The wordlist ships as a bundle resource atResources/Identity/bip39-english.txt. - Display: Shown exactly once during onboarding's recovery phase and once in
IdentityView.RecoveryPromptCardimmediately after a freshOsaurusIdentity.setup(). Rendered as a 4×6 grid with "Copy phrase", "Save as .txt", and "Print" actions. - Memory hygiene: The 32-byte seed is held only on the stack of
OsaurusIdentity.setup()long enough to compute the mnemonic, then wiped viaData.zeroOut()(which callsmemsetover the underlying buffer). - Acknowledgement: A
masterMnemonicAcknowledgedUserDefaults flag (canonicalised inIdentityDefaultsKey) is set when the user confirms "I've saved it". On subsequent launches the Identity view shows a yellow "Master key backup not confirmed" banner whenever the flag is missing.
What the phrase does and does not recover. The phrase rebuilds the master only. Agent addresses are re-derived from the master plus each agent's stored key path, and the two layouts differ in what that path needs:
| Layout | Path | Recoverable from the phrase alone? | Recoverable from the phrase plus the Agent record? |
|---|---|---|---|
| v1 (legacy) | index |
Effectively yes — the index space is tiny, so a known address can be matched by scanning | Yes |
| v2 (device-scoped) | (deviceScope, index) |
No. The scope is an opaque 8-hex device ID that exists only on the Agent record; there is no way to rediscover it from the phrase or from the address |
Yes — agentDeviceScope is a field on Agent |
Where the (deviceScope, index) path lives, and therefore what counts as a backup of a v2 agent address:
- The
AgentJSON on disk (~/.osaurus/agents/<id>.json) and anything that copies it — Time Machine,AgentStorebackups. - An
.osaurus-agentbundle:agent.jsonis the fullAgentencoding, so the scope travels with the export. - Not the Workspaces roster:
OsaurusRouterWorkspaceShareAgentBody/ the roster row carryagent_address, display name, description and proof — no scope and no index. Knowing an address from the router (or from a teammate's pairing) is not enough to re-derive it.
So a wiped Mac restored from the phrase gets its identity back and can prove ownership of any address it later learns the path for, but it cannot reconstitute a v2 agent's address without a copy of that agent's record. This is a regression relative to v1's scan-the-indices fallback and is the price of collision-free addresses across devices; keep agent exports or a ~/.osaurus backup alongside the phrase. RecoverFromMnemonicSheet handles both layouts because it confirms the candidate master against each agent's stored agentKeyPath, not against a bare index.
OSAURUS-XXXX-XXXX-XXXX-XXXX
Format: OSAURUS- prefix followed by 4 groups of 4 uppercase hex characters (8 random bytes = 64 bits of entropy).
- Generated from
SecRandomCopyBytesand shown to the user exactly once during initial setup. - Single-use; consumed when claimed against the Osaurus directory.
- Cannot rebuild the local master — it is only an authenticator for the future server-side recovery flow.
- Discarded from application memory after display; never stored on device in plaintext.
When the persisted derivatives no longer match the current master (see Identity Health and Drift), the Identity view surfaces three actions in a banner:
| Action | What it does | When to use |
|---|---|---|
| Recover from phrase | Opens RecoverFromMnemonicSheet, validates the entered 24 words against the BIP39 wordlist + checksum, derives a candidate OsaurusID, and confirms it reproduces the persisted agent addresses before calling MasterKey.install(seed:allowReplace: true). Drift goes away because every existing derivative now matches. |
The user has the original mnemonic from onboarding and wants their old identity back. |
| Repair forward | Re-derives every mismatched agent at a fresh unused index off the current master and revokes every stale osk-v1 access key in one synchronous pass. The HTTP server is restarted so the new validator picks up the change. | The original mnemonic is gone. Existing pairings will need to be re-issued. |
| Reset identity | OsaurusIdentity.wipe() deletes the master, clears every non-built-in agent's address/index, calls APIKeyManager.deleteAll(), clears the masterMnemonicAcknowledged flag, then triggers OnboardingService.resetOnboarding(). The revocation store is intentionally kept (cheap and harmless). |
Nuclear option. Start completely fresh. |
The Recover path will refuse to overwrite the current master if the entered phrase derives a different identity than the one the persisted agents were minted under, unless the user explicitly clicks "Restore Anyway". This catches the case where the user pastes a valid but unrelated BIP39 phrase.
Identity/IdentityHealthCheck.swift is a pure synchronous helper that, given an already-unlocked master key, returns an IdentityDrift value:
public struct IdentityDrift: Sendable {
public let mismatchedAgents: [Agent] // stored agentAddress != deriveAddress(currentMaster, agentKeyPath)
public let staleAccessKeys: [AccessKeyInfo] // iss is neither the current master nor any current agent
public let recoverableScopeAgents: [Agent] // scope dropped by an older build; v2 path under THIS device re-derives the address
public var hasDrift: Bool { ... } // mismatched || stale — recoverable agents are not drift
}diagnose(masterKey:agents:accessKeys:currentDeviceScope:) takes this device's ID so it can tell a lost scope from a lost master: an agent with agentDeviceScope == nil whose v1 derivation does not match but whose v2 derivation under currentDeviceScope reproduces the stored address exactly was re-saved by a build that predates the field (downgrade → upgrade). That is lossless to fix — AgentManager.repairDeviceScope(for:scope:) writes the scope back without touching the address, index, or any minted key — so the Identity view (and OsaurusIdentity.restore) repair it automatically before rendering the banner, and show "Restored the device scope for N agent address(es) saved by an older version. No keys were changed." A dropped scope diagnosed on a different device stays mismatched, because nothing lossless can be done there.
The helper does no Keychain or biometric work itself — the Identity view performs the biometric unlock once on load, passes the bytes in, and wipes them after the call.
Why drift happens: Pre-fix, MasterKey.generate() would silently delete() then write a new master whenever onboarding re-ran. Every pre-existing derivative (agent addresses minted via AgentKey.deriveAddress(masterKey, index), every osk-v1 key signed by either the master or an agent) still pointed at addresses derived from the previous master, but the validator would only accept derivatives of the current one. Requests would fail with opaque "audience mismatch" / "issuer not whitelisted" errors with no UI hint that the master had silently changed.
What the health check catches:
- Mismatched agents —
agent.agentAddress.lowercased() != AgentKey.deriveAddress(masterKey: currentMaster, path: agent.agentKeyPath).lowercased()for any non-built-in agent with an address. Each agent is checked against its own stored derivation (v1 or v2), so a legacy agent is not flagged just because the v2 derivation at the same index differs, and an agent minted on another device under a different scope is healthy as long as its own(scope, index)re-derives correctly. Built-in agents and agents without an address yet are skipped. - Stale access keys —
key.issdoes not match the current master and does not match any agent's current derived address. Revoked keys are ignored. The check tolerates rotated agents (it knows about both the old and new derived address while drift is still being repaired).
When drift.hasDrift is true, IdentityView renders an IdentityDriftBanner at the top of the scroll view with the three exit doors above. The same banner shows a one-line summary like "3 agent address(es) and 2 access key(s) reference a previous master."
The AgentAddressesSection of the Identity view renders each non-built-in agent as an expandable row backed by Views/Identity/AgentKeyManagement.swift.
The collapsed row is a compact summary: address, copy button, "Stale" pill if the agent appears in IdentityDrift.mismatchedAgents, and a chevron. Expanded, the row exposes:
- Rotate Key —
AgentManager.rotateAddress(of:). Picks a fresh unused v2 path under this device's scope, derives a new address, persists it, and revokes every active osk-v1 key whose audience matched the previous address. It returns anAddressRotation { agentId, previousAddress, newAddress, newKeyPath }andIdentityView.rotateKeypropagates it to every surface that pinned the old address: the HTTP server restarts so the new validator takes effect;RelayTunnelManager.handleAddressRotated(agentId:previousAddress:)sendsremove_agentfor the old address andadd_agent(freshly signed) for the new one on the live tunnel and clears anysupersededmark;WorkspacesService.migrateShares(from:to:)re-shares the new address into every workspace that listed the old one (share first, then unshare, so no workspace is ever left with neither), kills the workspace-minted keys for the old address, and re-keys the per-agent pool-billing preference;OwnerDeviceAccessHost.forgetRecords(agentAddress:)drops the owner-device key records (their keys were already revoked). Workspaces whose re-share failed are listed in the error message for a manual re-share. - Revoke —
AgentManager.revokeAddress(of:). Clears the agent's address and index and revokes every active osk-v1 key scoped to it. The agent's prompt and settings stay intact. - Per-agent osk-v1 access keys. A scoped list of every key whose
audmatches the agent's address (viaAPIKeyManager.listKeys(forAudience:)). Each key shows its label, status pill (Active / Expired / Revoked), expiration, and a per-key Revoke button. - Generate access key (scoped to this agent). Opens the shared
AccessKeyGeneratorSheetwith the agent'sagentKeyPathpre-filled, soAPIKeyManager.generate(label:expiration:agentKeyPath:)mints an agent-scoped key. The sheet displays a "Scoped to {agent name} ({address})" caption to make the scope unambiguous. The freshly generated key is shown in a one-shot copy banner ("Copy this key now. It won't be shown again.") and is never persisted to disk.
The same AccessKeyGeneratorSheet powers the global AccessKeysSection in ServerView (master-scoped). It was extracted from ServerView.swift into Views/Settings/AccessKeyGeneratorSheet.swift so both call sites share one widget.
The identity system supports two communication modes:
Agents within the same Osaurus instance authenticate using the full two-layer token system:
- Layer 1: secp256k1 identity signature (master or agent key)
- Layer 2: App Attest device assertion
This provides the strongest authentication: both the cryptographic identity and the physical device are verified. Requires biometric access to the master key.
External tools, MCP clients, and remote agents authenticate using osk-v1 access keys:
- Single-layer: secp256k1 signature only (no device assertion)
- Portable: can be used from any device or service
- Scopable: master-scoped (all agents) or agent-scoped (single agent)
- Revocable: individual or bulk revocation without affecting other keys
Access keys bridge the gap between the hardware-bound internal identity and the need for third-party integrations that can't access the Secure Enclave.
The LAN pairing flow is a challenge-response protocol between an in-app connector and a Bonjour-discovered agent. It is unauthenticated (no pre-existing key) but signature-verified in both directions and end-to-end sealed.
- Challenge. Connector calls
GET /pair/challenge; the host issues a single-use nonce (~2 minute TTL, tracked inPairingChallengeStore). Connector-chosen nonces are not accepted, so a captured/pairrequest cannot be replayed. - Request. Connector generates an ephemeral X25519 keypair and signs
nonce+ ephemeral public key (encPub) with itsconnectorAddressprivate key (domain prefixOsaurus Signed Pairing), thenPOSTs to/pair. - Verification and approval. The host verifies the signature, atomically consumes the nonce, resolves the target agent, and shows an approval dialog naming both the connector and the agent. Prompts are serialized (a concurrent request gets
429 busy), auto-deny after a 2-minute timeout, and only accept Return when the prompt window is key — keystrokes in other apps cannot approve a pairing./pairand/pair/challengeare rate-limited per source IP, with a cooldown after a denial. - Sealed key delivery. On approval, the host mints an agent-scoped
osk-v1key for the approved agent (signed alongagent.agentKeyPath) with a 90-day expiration by default ("Remember this device permanently" opts into a non-expiring key). The key is HPKE-sealed (X25519 + HKDF-SHA256 + ChaCha20-Poly1305, viaPairingKeyEnvelope) to the connector's ephemeralencPub, with the agent address and nonce bound into the HPKEinfo— a passive observer on the LAN never sees the plaintext key, and an envelope cannot be transplanted to a different exchange. - Server identity verification. The response carries a server signature (agent key) over the challenge and key fingerprint. The connector recovers the signer address and requires it to match the
addressit discovered in the agent's Bonjour TXT record, defeating spoofed advertisements. - The response body containing the new key is sent on the wire but never persisted to the request log —
InsightsServiceredacts bothapiKeyJSON values andBearer osk-…headers as defense-in-depth across all logged bodies.
Pairings approved before the agent-scoping fix are master-scoped, never-expiring keys. The Settings → Server pane labels them as Legacy and explains: "Pre-upgrade pairing — grants access to all agents and never expires." Users can revoke and re-pair to scope them tighter.
Agents can be exposed through the Osaurus Relay (agent.osaurus.ai), which proxies public HTTPS traffic over a WebSocket tunnel into the local server at 127.0.0.1.
- No loopback trust for relayed traffic.
RelayTunnelManagerstamps every proxied request with an internal marker header (set after copying the external caller's headers, so a remote caller cannot suppress or forge a trusted state). The HTTP handler treats marked requests as non-loopback: they always pass through the full auth gate, CORS origin rules, and the built-in-agent remote block, even whenExpose to networkis off. - Relay pairing (
/pair-invite) consumes a signed, single-useAgentInviteand supports the same HPKE sealed-key delivery as LAN pairing: the connector supplies an ephemeralencPub, and the minted key is returned only inside a sealed envelope. The relay operator (a TLS-terminating MITM by construction) never observes plaintext credentials in transit. - Tunnel auth uses a server-issued nonce challenge. Each agent on the tunnel signs
osaurus-tunnel:<agentAddress>:<nonce>:<timestamp>with its own derived child key (AgentKey.sign(payload:masterKey:path:)along the agent'sagentKeyPath), not the master; the relay recovers the signer and requires it to equal the claimed address. The master key material is zeroed immediately after derivation, and transient auth failures retry with bounded exponential backoff. - Owner redeem (
/pair-invite,{"owner_redeem": …}). A device holding the same master (a phone signed into the user's iCloud Keychain, a second Mac restored from the phrase) obtains an agent-scoped osk-v1 key for an agent hosted here without a Workspace, a router attestation, or an invite. Step one issues a 120 s single-use nonce bound to(agent_address, device_id)— one outstanding per pair (a repeat replaces it) under a hard table cap, so unauthenticated step-one traffic cannot grow the table; step two must carry a fresh X25519encPub(required, validated before the nonce is consumed;400without it) and an EIP-191 master signature overosaurus-owner:redeem:<agent_address_lower>:<nonce>that recovers to this host's own master address (derived non-interactively, as the validator does). Then: agent must be hosted here (404), must not be built-in (403,Agent.rejectBuiltInForExternalSurface), key is minted with labelOwner device – <device_name>and a 90-day expiry, HPKE-sealed toencPub(never returned in plaintext — the relay terminates TLS and is untrusted by design), and recorded in~/.osaurus/identity/owner-devices.json(one live key per device + agent; a re-redeem replaces the previous key). Implementation:Services/Auth/OwnerDeviceAccessHost.swift; full wire contract inMOBILE_PROTOCOL.md. - One tunnel per address. The relay maps each agent address to exactly one tunnel; a newer authenticated tunnel for the same address evicts the older one with
agent_removed reason:"superseded".RelayTunnelManagerhonors this: the evicted agent is markedAgentRelayStatus.servedElsewhere(surfaced as "Served From Another Device" / "On another device" in the relay status UI), its address is dropped from the auth frame on subsequent reconnects, and if no agents remain the tunnel stops reconnecting instead of ping-ponging with the other device. The superseded set is cleared on a user-initiated relay toggle/reconnect ("Serve From This Mac") and on relaunch. With v2 device-scoped addresses, supersession only happens for genuinely duplicated agent state (e.g. a restored backup running on two machines) — two devices minting independently never collide.
All Osaurus-to-Osaurus agent traffic — LAN peers discovered over Bonjour and remote peers behind the relay — runs inside the Osaurus Secure Channel v1, a forward-secret, mutually authenticated encrypted layer above HTTP. (Full feature document: SECURE_CHANNEL.md.)
Handshake (POST /secure/session). A SIGMA-style signed-ephemeral exchange:
- The client sends an ephemeral X25519 public key, a freshness nonce, and the agent address it expects to reach (pinned at pairing/discovery time).
- The server replies with its own ephemeral X25519 key, a session id, an expiry (1 hour), and a secp256k1 agent-key signature over the full handshake transcript (domain prefix
Osaurus Secure Channel). For the paired phone the signer may instead be the Mac's connect identity (see Agent Key); an address that is neither gets404 Unknown agent address. - The client recovers the signer address from the signature and requires it to match the pinned agent address — a MITM cannot fake this without the agent's private key.
- Both sides derive directional ChaCha20-Poly1305 keys via HKDF-SHA256 over the X25519 shared secret, salted with the transcript hash.
Because both X25519 keys are ephemeral, the channel has forward secrecy: compromising long-term identity keys later never decrypts recorded sessions (HPKE base mode, used for pairing key delivery, deliberately does not provide this — hence a handshake rather than per-request HPKE). The endpoint is rate-limited per source IP (PairingRateLimiter) and grants nothing by itself: requests inside the channel still pass the full osk-v1 auth gate.
Encrypted calls (POST /secure/call). The ciphertext decrypts to the inner HTTP request — method, path, Authorization Bearer, headers, body — so after pairing, credentials and prompt content never cross the LAN or relay in plaintext. Server-side, decryption happens at the single request choke point and the inner request flows through the existing auth gate, agent-scope check, and routing untouched. Responses are sealed on the way out by an outbound pipeline stage (SecureChannelResponseEncryptor): buffered JSON becomes a single authenticated frame (real status code inside the ciphertext); SSE streams become per-event encrypted frames ending in an authenticated fin marker.
Framing guarantees:
- Request sequence numbers are session-monotonic with an IPsec-style sliding anti-replay window — a captured
/secure/callcan never re-execute (409 secure_replay). - Response frames use a per-call derived key and strict in-order sequence numbers; reordered, replayed, or cross-call frames fail authentication.
- Streams must end with the authenticated
finframe; silent truncation by a relay or middlebox is detected client-side. - Sequence numbers double as AEAD nonces and are bound into the AAD with the session id and direction, so frames cannot be transplanted between sessions, directions, or calls.
Hard-require (no downgrade). Non-loopback requests to /agents/{id}/run and /agents/{id}/dispatch — including relay-origin traffic — that did not arrive through the channel are rejected with 426 Upgrade Required (secure_channel_required). Loopback callers (CLI, App Intents) stay plaintext. /models and agent metadata routes keep accepting plaintext for third-party SDK clients, though Osaurus peers fetch them through the channel too. Peers advertise support via osc=1 in their Bonjour TXT record and a secureChannel flag in pair/invite responses; an old peer that cannot handshake produces a clear "upgrade Osaurus" error instead of a cryptic failure.
With the channel in place, the relay is a blind pipe: it forwards only handshake messages and ciphertext, and the relay operator (a TLS-terminating MITM by construction) can no longer observe Bearer tokens, prompts, or responses.
Both Osaurus HTTP servers reject oversized request bodies before the auth gate runs, so an unauthenticated client cannot exhaust host memory:
| Endpoint | Limit | Configurable via |
|---|---|---|
POST /pair |
64 KiB | ServerConfiguration.maxPairingBodyBytes |
| Other public HTTP routes | 32 MiB | ServerConfiguration.maxRequestBodyBytes |
| Sandbox host bridge | 8 MiB | hard-coded in HostAPIBridgeHandler |
Both servers enforce the cap with a Content-Length pre-check at request head and a streaming guard at body chunks, so chunked clients and clients that lie about their declared length both hit 413 Payload Too Large.
The address-based design naturally extends to agent-to-agent communication across different Osaurus instances. Since every agent has a globally unique address and can sign messages, agents can verify each other's identity without a shared authority — only knowledge of the other agent's address is needed.
| Property | Mechanism |
|---|---|
| Master key never leaves Keychain | Stored with kSecAttrAccessibleWhenUnlocked, read requires LAContext biometric auth |
| Master key cannot be silently overwritten | MasterKey.generate(allowReplace:) defaults to false and throws masterAlreadyExists if a master is present; OsaurusIdentity.setup() short-circuits when one already exists |
| Master key has a local restore path | BIP39 24-word mnemonic shown once at setup; entered via RecoverFromMnemonicSheet to call MasterKey.install(seed:allowReplace: true) |
| Agent keys never stored | Re-derived on demand via HMAC-SHA512 from master key |
| Multi-device: unique addresses | v2 derivation binds the minting device's ID, so devices sharing one master mint disjoint agent addresses with no coordination |
| Multi-device: same identity everywhere | Master syncs via iCloud Keychain or phrase; device attestation is ensured on load/restore, never gated on master creation |
| Agent indices are never reused | AgentManager.nextUnusedAgentIndex() always picks a fresh slot so old derived addresses cannot be regenerated by the rotate path |
| Device keys hardware-bound | Secure Enclave P-256 via App Attest (DCAppAttestService) |
| Anti-replay | Per-device monotonic counter (cnt) persisted in UserDefaults; server rejects seen values |
| Domain separation | Osaurus Signed Message, Osaurus Signed Access, Osaurus Signed Pairing, and Osaurus Secure Channel prefixes prevent cross-protocol signature reuse |
| Recovery code single-use | Generated from SecRandomCopyBytes, shown once, never stored on device |
| Canonical encoding | Access key payloads use sorted-key JSON for deterministic signature verification |
| Memory safety | Master key bytes and seed bytes are zeroed after use via Data.zeroOut() extension (calls memset over the underlying buffer) |
| Pairings scoped to one agent | /pair mints agent-scoped keys (agentIndex from approved agent), 90-day default expiry |
| Pairing is replay-proof | /pair only accepts signatures over a server-issued, single-use nonce from GET /pair/challenge (~2 min TTL, atomically consumed by PairingChallengeStore) |
| Pairing server is verified | /pair response carries a server signature over challenge + key fingerprint; the connector requires the recovered address to match the Bonjour TXT address |
| Minted keys sealed in transit | /pair and /pair-invite HPKE-seal the fresh osk-v1 key (X25519 + HKDF-SHA256 + ChaCha20-Poly1305) to the connector's ephemeral public key, with agent address + nonce bound into the HPKE info |
| Pairing endpoints rate-limited | PairingRateLimiter caps /pair and /pair/challenge per source IP, with a cooldown after a denial; the approval prompt is serialized, times out after 2 minutes, and only accepts Return when its window is key |
| Relayed traffic never loopback-trusted | RelayTunnelManager marks proxied requests with an internal header; the auth gate, CORS rules, and remote blocks treat them as remote even though they arrive over 127.0.0.1 |
| Agent traffic end-to-end encrypted | Secure Channel v1: signed-ephemeral X25519 handshake (forward secrecy), ChaCha20-Poly1305 framing, Bearer + body inside the ciphertext; relay carries only ciphertext |
| Peer identity verified per session | The server signs the handshake transcript with its agent key; the client requires the recovered address to match the address pinned at pairing — MITM servers cannot complete a handshake |
| Encrypted calls replay-proof | Session-monotonic request sequence numbers with a sliding anti-replay window; a captured /secure/call re-execution gets 409 secure_replay |
| Stream truncation detected | Encrypted responses end with an authenticated fin frame; a stream cut short by a relay or middlebox fails client verification instead of passing silently |
| No plaintext downgrade for agent routes | Non-loopback /agents/{id}/run and /dispatch requests outside the channel get 426 Upgrade Required (secure_channel_required) |
| Cross-agent key use rejected | Agent-scoped keys hitting another agent's /agents/{id}/... routes get 403 agent_scope_denied (audience checked against AgentIdentityRegistry) |
| Revocations apply immediately | APIKeyValidatorEpoch invalidates the cached validator on key/revocation/whitelist/agent changes; no server restart needed |
| Issued credentials never logged | /pair success path logs a redacted body; InsightsService.redactCredentials scrubs osk-v1 values and Bearer headers everywhere as a backstop |
| Pre-auth body-size limits | /pair capped at 64 KiB, other public routes at 32 MiB; rejected with 413 before the auth gate |
| Drift between master and derivatives is surfaced | IdentityHealthCheck.diagnose(...) runs once per Identity view load; mismatched agents and stale osk-v1 keys are rendered in an IdentityDriftBanner with explicit Recover / Repair / Reset actions instead of failing silently at request time |
| File | Responsibility |
|---|---|
MasterKey.swift |
Generate (generate(allowReplace:)), install a caller-supplied seed (install(seed:allowReplace:)), read, sign, and delete the secp256k1 master key in iCloud Keychain |
MasterKeyMnemonic.swift |
BIP39 24-word encode/decode of the 32-byte master, backed by the bundled English wordlist |
MasterMnemonicStore.swift |
iCloud Keychain item for the 24-word phrase, same service / access group as the master |
OsaurusKeychainGroup.swift |
Resolves the shared Keychain access group from Info.plist (nil in builds without the entitlement); pure dual-write order (writeAttemptGroups) and add-only mirror decision (mirrorPlan) |
IdentityHealthCheck.swift |
Pure helper that classifies persisted derivatives as healthy / mismatched / recoverable-scope against the current master |
AgentKey.swift |
Deterministic child key derivation (HMAC-SHA512, v1 master-global and v2 device-scoped), AgentKeyPath, and signing for per-agent identities |
DeviceKey.swift |
App Attest key generation, attestation, assertion, and software fallback |
OsaurusIdentity.swift |
Public entry point — orchestrates setup(), wipe(), and two-layer request signing |
IdentityModels.swift |
Data types: OsaurusID, TokenHeader, TokenPayload, AccessKeyPayload, AccessKeyInfo, AgentInfo, RevocationSnapshot, IdentityInfo (now carries mnemonic), and the IdentityDefaultsKey namespace for UserDefaults flags |
APIKeyManager.swift |
Generate, persist, and revoke osk-v1 access keys (metadata in Keychain). Includes listKeys(forAudience:) for per-agent scoping |
APIKeyValidator.swift |
Immutable, lock-free access key validation via ecrecover + whitelist + revocation; accepts master- and any agent-scoped audience |
AgentIdentityRegistry.swift |
Thread-safe snapshot of all agents' derived addresses/indices, maintained by AgentManager, read by the validator builder and the route-level scope check |
PairingChallengeStore.swift |
Single-use, TTL-bound nonces for the LAN /pair challenge-response flow |
PairingRateLimiter.swift |
Per-source-IP rate limiting and denial cooldown for the unauthenticated pairing endpoints |
PairingKeyEnvelope.swift |
HPKE (X25519 + HKDF-SHA256 + ChaCha20-Poly1305) sealing of freshly minted keys to the connector's ephemeral public key |
SecureChannel.swift |
Secure Channel v1 protocol core: signed-ephemeral X25519 handshake, transcript hashing, HKDF key schedule, AEAD framing with anti-replay window and fin markers |
SecureSessionStore.swift |
Server-side registry of established Secure Channel sessions (bounded, TTL-pruned) |
WhitelistStore.swift |
Master-level and per-agent address whitelist with Keychain persistence |
RevocationStore.swift |
Individual and bulk access key revocation with Keychain persistence |
CounterStore.swift |
Per-device monotonic counter in UserDefaults |
RecoveryManager.swift |
One-time recovery code generation at identity creation |
CryptoHelpers.swift |
Keccak-256, domain-separated signing, ecrecover, address derivation, encoding utilities, and Data.zeroOut() |
OsaurusIdentityError.swift |
Error types for the identity system, including masterAlreadyExists and the four mnemonic* validation cases |
| File | Responsibility |
|---|---|
IdentityView.swift |
The Identity tab: setup card, recovery prompt, ready state with master / agents / device / danger-zone sections, drift banner, and the three exit-door sheets / alerts |
MasterMnemonicCard.swift |
Numbered 4×6 grid of the 24-word BIP39 phrase with copy / save / print actions. Shared between onboarding and the recovery prompt |
AgentKeyManagement.swift |
Expandable per-agent row: rotate / revoke address + scoped osk-v1 list + scoped generate/revoke |
RecoverFromMnemonicSheet.swift |
Phrase-entry sheet with live word count, BIP39 validation, prior-master matching, and "Restore Anyway" override |
| File | Responsibility |
|---|---|
Views/Settings/AccessKeyGeneratorSheet.swift |
Modal sheet for generating an osk-v1 key. Used by ServerView (master-scoped) and IdentityView (agent-scoped via the optional scopeCaption) |
| File | Responsibility |
|---|---|
Services/Auth/OwnerDeviceAccessHost.swift |
owner_redeem envelope on /pair-invite: challenge table, master-signature verification against the host's own address, built-in guard, key mint + HPKE seal, per-device key records |
Services/AgentBridge/AgentBundleService.swift |
.osaurus-agent import identity rule (resolveImportIdentity): collision → clear + re-mint, foreign scope → keep as a move, legacy v1 → keep; surfaced as ImportPreview.identityNote |
docs/MOBILE_PROTOCOL.md |
Wire contract for a client-only (iOS / second Mac) Osaurus: bootstrap, discovery, key acquisition, Secure Channel, errors, compatibility |