apple: encryption-service oracle API for wrapping-key access#78
Merged
jgowdy-godaddy merged 1 commit intomainfrom Apr 24, 2026
Merged
apple: encryption-service oracle API for wrapping-key access#78jgowdy-godaddy merged 1 commit intomainfrom
jgowdy-godaddy merged 1 commit intomainfrom
Conversation
Shrinks the wrapping-key exposure surface. The cache still serves the
same purpose — hold the decrypted AES key so userPresence prompts
don't fire every sign — but the raw key bytes no longer cross the
\`keychain_wrap\` module boundary on reads.
Before: \`pub fn keychain_load(...) -> Result<Option<[u8; 32]>>\` and
\`pub fn encrypt_blob\` / \`pub fn decrypt_blob\` exposed the 32-byte
AES key as a plain \`Copy\` array to every caller inside the crate.
A log statement, serialization mistake, or overeager clone anywhere
in \`keychain.rs\` (or any future caller) could leak the key.
After: public API from \`keychain_wrap\` is three functions that
perform the crypto internally and return only the result:
- \`generate_and_wrap(app, label, plaintext, use_user_presence)
-> Result<Vec<u8>>\`
Creates a wrapping key, stores it in the keychain with the
requested access-control policy, encrypts the plaintext, returns
only the wrapped blob. If any intermediate step fails, the
half-stored keychain entry is cleaned up before returning.
- \`decrypt_with_cached_key(app, label, blob, cache_ttl)
-> Result<Option<Vec<u8>>>\`
Consults the process-local cache; on miss, reads from the
keychain (prompting on userPresence items), caches for the TTL,
decrypts the blob, returns only the plaintext. \`None\` means no
wrapping-key entry exists.
- \`relabel_wrapping_key(app, old, new, use_user_presence)
-> Result<()>\`
Moves a wrapping-key entry from one label to another atomically
from the caller's point of view. The raw key is loaded, re-stored
under the new label, and the old entry is deleted — all inside
the function.
The trio of helpers that used to be \`pub\` (\`keychain_store\`,
\`keychain_load\`, \`generate_wrapping_key\`, \`encrypt_blob\`,
\`decrypt_blob\`) are now \`pub(crate)\` — they're still callable from
the crate's tests and internally, but they're no longer part of the
library's public surface.
Callers in \`keychain.rs\` updated:
- \`generate_and_save_key\` switches to \`generate_and_wrap\`.
- \`rename_key\` switches to \`relabel_wrapping_key\` + a simpler
disk-then-keychain ordering with symmetric rollback.
- \`load_handle\` switches to \`decrypt_with_cached_key\`.
Behavior is unchanged — this is purely a surface reduction. Threat
model: doesn't stop an attacker with \`task_for_pid\` / ptrace debug
entitlement (they can still read live memory), but makes
memory-disclosure bugs in the *caller* unable to spill the wrapping
key, since the key never appears in any caller's stack or return
value.
All existing tests pass (\`cargo test --workspace\`); clippy and fmt
both clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Shrinks the wrapping-key exposure surface without changing user-visible
behavior. The process-local TTL cache from #77 still serves the same
role, but the raw 32-byte AES key no longer crosses the
`keychain_wrap` module boundary.
Why
The caller in #77 looked like:
```rust
// keychain.rs::load_handle — hot path, runs on every sign
let wrapping_key = keychain_wrap::keychain_load(app, label, ttl)?;
crate::keychain_wrap::decrypt_blob(&wrapping_key, &contents)
```
The raw AES key lived in at least three places during each sign:
the cache entry (intended, `mlock`ed, zeroized), the `Result`
return value (transient, not zeroized), and `decrypt_blob`'s local
binding (transient). A log statement, serialization mistake, or
overeager clone in any caller could leak it.
Change
Public API from `keychain_wrap` is three functions that perform the
crypto internally and return only the result:
-> Result<Vec>` — create key, store with access-control
policy, encrypt plaintext, return wrapped blob. Rolls back the
keychain entry on intermediate failure.
-> Result<Option<Vec>>` — consult cache, keychain on miss
(prompts on userPresence items), return only the decrypted
plaintext.
-> Result<()>` — atomically move an entry from one label to
another. Raw key is loaded, re-stored, and old entry deleted all
inside the function.
The old helpers that exposed raw bytes (`keychain_store`,
`keychain_load`, `generate_wrapping_key`, `encrypt_blob`,
`decrypt_blob`) are now `pub(crate)` — still callable from the
crate's own tests and the keychain.rs module, no longer part of the
library surface.
Callers in `keychain.rs` updated:
ordering with symmetric rollback.
Threat model delta
Stops a caller-level memory-disclosure bug from spilling the wrapping
key. Doesn't change anything for a `task_for_pid` / ptrace attacker
with live-memory read access; they were always going to win against
any SSH agent, same as before.
Test plan
`wrapping-key-user-presence` branch; no sshenc changes needed — the
new oracle API is a drop-in on the sshenc side via
`SshencBackend` which already goes through `AppSigningBackend`
→ `SecureEnclaveSigner` → `keychain.rs`. sshenc sees the
decrypted handle as before; it never saw the raw wrapping key.