September 13, 2026

Entelecheia builds software for handling confidential data, and secure local storage is foundational to that work. We’re open-sourcing our approach to storing data securely on macOS desktop, giving users of our products visibility into this part of our operation. We welcome feedback from other developers on how to make it more secure still.

Our desktop app caches things on disk that shouldn’t be readable by every other process on the machine: session tokens, drafts of sensitive notes, local copies of data that’s otherwise encrypted on our servers. Writing that as plain JSON to the app’s data directory and relying on the OS’s file permissions was never good enough — any other tool running as the same user, whether a backup utility, a sync client, or something malicious, could read it just as easily as the app could.

One key, many secrets

The approach: generate a random symmetric key the first time the app runs, store it in the macOS Keychain instead of a file the app manages itself, and use it to AES-encrypt everything else the app wants to persist before writing it to disk. The Keychain is the load-bearing part — the same OS-level secret store used for Wi-Fi passwords and Safari logins, gated by the OS’s own access control rather than the app’s own code. One key, used consistently, then protects every file, database row, or cache entry the app writes, without a per-feature encryption scheme each time.

The relevant Keychain accessibility attribute is kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. “After first unlock” means the key is readable once the user has unlocked the Mac since boot, so a background helper can decrypt data without prompting again. “This device only” means the item is excluded from iCloud Keychain sync and from backups — it never leaves the machine it was created on.

That backup exclusion matters a lot to us, since our app deals with confidential data. Time Machine, iCloud backup, and sync tools routinely copy an app’s entire data directory. Had we kept the device key in a regular file next to the encrypted data, a backup would have copied both together, and anyone who later got hold of it — a stolen drive, a compromised iCloud account, a restored second machine — would have recovered the key right alongside the ciphertext. ThisDeviceOnly breaks that pairing: our encrypted files get backed up freely since they’re just ciphertext, but the key that unlocks them can’t leave the original Mac through any backup mechanism. The rule we follow: never let the key and the data it protects travel through the same channel.

Core implementation

The core API is three calls — save, load, delete — each identified by a service name and an account name, the same two fields Keychain-based tools like keytar use:

import Security

public class MacDeviceKeyHelper {
    public static func save(key: String, account: String, data: Data, accessGroup: String? = nil) -> OSStatus {
        var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: key,
            kSecAttrAccount as String: account,
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
        ]
        if let group = accessGroup {
            query[kSecAttrAccessGroup as String] = group
        }

        // SecItemAdd fails if an item already exists under this
        // service+account, so clear it first and re-add.
        SecItemDelete([kSecClass as String: kSecClassGenericPassword,
                       kSecAttrService as String: key,
                       kSecAttrAccount as String: account] as CFDictionary)

        return SecItemAdd(query as CFDictionary, nil)
    }

    public static func load(key: String, account: String, accessGroup: String? = nil) -> Data? {
        var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: key,
            kSecAttrAccount as String: account,
            kSecReturnData as String: kCFBooleanTrue!,
            kSecMatchLimit as String: kSecMatchLimitOne
        ]
        if let group = accessGroup {
            query[kSecAttrAccessGroup as String] = group
        }

        var item: AnyObject?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        return status == errSecSuccess ? item as? Data : nil
    }
}

There’s nothing exotic here — it’s a thin wrapper over SecItemAdd / SecItemCopyMatching / SecItemDelete, the same Keychain Services calls any macOS app can use. The value of packaging it as a library is that the accessibility attribute and the delete-then-add pattern (Keychain updates are awkward otherwise) are easy to get subtly wrong, and getting them wrong quietly weakens the guarantee.

The optional accessGroup parameter lets multiple binaries signed by the same team — say, a helper tool and the main app — share one Keychain item, which matters once the consumer isn’t a plain macOS app (see below).

Consuming it from a non-Swift app: an Electron example

We implement some of our products using cross-platform systems like Electron rather than native Swift. The library also exports a plain C ABI (@_cdecl functions with char*/int32 signatures) specifically so it can be linked into anything that can call a native .a/.dylib, without requiring the host app to embed a Swift runtime.

@_cdecl("fetchKey")
public func fetchKey(keyPtr: UnsafePointer<CChar>, accountPtr: UnsafePointer<CChar>,
                      outBuffer: UnsafeMutablePointer<CChar>, bufferSize: Int,
                      accessGroupPtr: UnsafePointer<CChar>) -> Int32 {
    let key = String(cString: keyPtr)
    let account = String(cString: accountPtr)
    let accessGroup = String(cString: accessGroupPtr)
    guard let data = MacDeviceKeyHelper.load(key: key, account: account,
                                              accessGroup: accessGroup.isEmpty ? nil : accessGroup) else {
        return -1
    }
    guard data.count <= bufferSize else { return -2 }
    data.withUnsafeBytes { bytes in
        outBuffer.initialize(from: bytes.bindMemory(to: CChar.self).baseAddress!, count: data.count)
    }
    return Int32(data.count)
}

An Electron app on macOS can reach that through a small Node native addon (N-API), passing strings and buffers across the boundary and getting back the raw key bytes:

// addon.cpp — declares the symbols exported by the statically linked
// Swift library and forwards N-API calls into them.
extern "C" {
    int saveKey(const char* key, const char* account,
                const char* data, int len, const char* access_group);
    int fetchKey(const char* key, const char* account,
                 char* out_buf, int buf_len, const char* access_group);
}

On the JavaScript side, the app fetches (or, on first launch, generates and saves) a 256-bit key and caches it in memory for the life of the process:

async function get_device_key_macos(key_name, account_name) {
  const addon = require('../../native-addons/native');
  const access_group = 'TEAMID.com.example.desktop';

  const existing = addon.fetch_key(key_name, account_name, access_group);
  if (existing) return existing.toString('utf8');

  const new_key = crypto.randomBytes(32).toString('hex');
  addon.save_key(key_name, account_name, Buffer.from(new_key, 'utf8'), access_group);
  return new_key;
}

Everything above the addon boundary — the rest of the Electron main process — never touches Security.framework directly. As far as the app’s JavaScript is concerned, it just calls a function and gets a key back. On platforms where the native addon isn’t available (Windows, Linux), the same interface can fall back to another OS-backed secret store, such as keytar, so the app has a consistent key-retrieval API across platforms while macOS specifically gets the Keychain-backed implementation.

Turning the key into general-purpose at-rest encryption

With a device key available, encrypting arbitrary application data is just two small functions layered on top of Node’s crypto module:

async function local_encrypt(plaintext) {
  const key = await get_device_key();          // cached Buffer, 32 bytes
  const iv = crypto.randomBytes(16);
  const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
  let encrypted = cipher.update(plaintext, 'utf8', 'base64');
  encrypted += cipher.final('base64');
  return iv.toString('hex') + encrypted;        // IV travels with the ciphertext
}

async function local_decrypt(encryptedData) {
  const key = await get_device_key();
  const iv = Buffer.from(encryptedData.substring(0, 32), 'hex');
  const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
  let decrypted = decipher.update(encryptedData.substring(32), 'base64', 'utf8');
  return decrypted + decipher.final('utf8');
}

From here, encrypting anything the app persists is a matter of routing it through these two functions before it touches disk:

const encrypted_data = await local_encrypt(JSON.stringify(note_data));
store.set(record_key, encrypted_data);
// ...
const note_data = JSON.parse(await local_decrypt(store.get(record_key)));

The data model doesn’t need to know anything about encryption — it just reads and writes through local_encrypt/local_decrypt the same way it would read and write plain JSON. The important property is that the key that makes this reversible never appears on disk in a form any other process can read. Copy the app’s entire data directory to another machine, or hand it to another tool running under the same user account, and it’s ciphertext with no key attached — the Keychain item doesn’t travel with a file copy, and kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly means it won’t travel with an iCloud or Time Machine restore either.

You can view our full implementation here: github.com/entelecheia-inc/mac-device-key.

Keep reading

  1. JavaScript Backdoors and Page Integrity

    September 5, 2026

    In an effort to make the web safer, Entelecheia is sharing this proposed architecture with browser developers, standards organizations, and others working on the future of the web. …

    Continue reading

Entelechy, (from Greek entelecheia), in philosophy, that which realizes or makes actual what is otherwise merely potential. — Encyclopedia Britannica