{"_id":"@amritk/lynx-secure-storage","_rev":"2-fed2ee5e6fee440e427f3dfdf35c050e","name":"@amritk/lynx-secure-storage","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"@amritk/lynx-secure-storage","version":"0.1.0","keywords":["lynx","secure-storage","keychain","keystore","encrypted-shared-preferences","token","native-module","typescript","mjst"],"author":{"name":"amritk"},"license":"MIT","_id":"@amritk/lynx-secure-storage@0.1.0","maintainers":[{"name":"amritk","email":"amrit+spam@hockey-community.com"}],"homepage":"https://github.com/amritk/mini/tree/main/packages/lynx-secure-storage#readme","bugs":{"url":"https://github.com/amritk/mini/issues"},"dist":{"shasum":"717f2211d56ec7df361f7dc894d4e2042d1facaf","tarball":"https://registry.npmjs.org/@amritk/lynx-secure-storage/-/lynx-secure-storage-0.1.0.tgz","fileCount":53,"integrity":"sha512-XcQpgXIuPyfySrCNkN1as3JPssfIPBL2dF5FbinszVm7JHzIu07vA3aFJxyUrTsThYGrVshu+bypaUo3cf4t4Q==","signatures":[{"sig":"MEUCIQD4vhq1GzF720tkA63dP74r8TdhBk3iKEwmLpVETnttfQIgfov0xuskJKmaIP0yx+eiq6KBdsXs9k0Ig/ogPnT45zc=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":141898},"type":"module","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./testing":{"types":"./dist/testing/index.d.ts","import":"./dist/testing/index.js","default":"./dist/testing/index.js"},"./package.json":"./package.json","./lynx.lib.json":"./lynx.lib.json"},"gitHead":"b4bd35d7e300ad1758c0f7c6dd5f409d2cba77ae","scripts":{"test":"NODE_ENV=production vitest run --root ../.. packages/lynx-secure-storage/","build":"tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs","types:check":"tsgo -p . --noEmit","prepublishOnly":"node ../../scripts/check-publishable.mjs"},"_npmUser":{"name":"amritk","email":"amrit+spam@hockey-community.com"},"repository":{"url":"git+https://github.com/amritk/mini.git","type":"git","directory":"packages/lynx-secure-storage"},"_npmVersion":"11.16.0","description":"Encrypted key-value storage for Lynx: Keychain on iOS, EncryptedSharedPreferences on Android, and a promise-shaped facade that reaches them from the main thread.","directories":{},"sideEffects":false,"_nodeVersion":"26.3.0","dependencies":{"@amritk/mini-lynx-native":"0.2.1"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"typescript":"^5"},"peerDependencies":{"typescript":"^5"},"peerDependenciesMeta":{"typescript":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/lynx-secure-storage_0.1.0_1786082346817_0.5705199363090909","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@amritk/lynx-secure-storage","version":"0.2.0","description":"Encrypted key-value storage for Lynx: Keychain on iOS, EncryptedSharedPreferences on Android, and a promise-shaped facade that reaches them from the main thread.","type":"module","license":"MIT","author":{"name":"amritk"},"sideEffects":false,"keywords":["lynx","secure-storage","keychain","keystore","encrypted-shared-preferences","token","native-module","typescript","mjst"],"repository":{"type":"git","url":"git+https://github.com/amritk/mini.git","directory":"packages/lynx-secure-storage"},"homepage":"https://github.com/amritk/mini/tree/main/packages/lynx-secure-storage#readme","bugs":{"url":"https://github.com/amritk/mini/issues"},"publishConfig":{"access":"public"},"scripts":{"build":"tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f && node ../../scripts/strip-comments.mjs","prepublishOnly":"node ../../scripts/check-publishable.mjs","types:check":"tsgo -p . --noEmit","test":"NODE_ENV=production vitest run --root ../.. packages/lynx-secure-storage/"},"exports":{"./package.json":"./package.json","./lynx.lib.json":"./lynx.lib.json",".":{"types":"./dist/index.d.ts","import":"./dist/index.js","default":"./dist/index.js"},"./testing":{"types":"./dist/testing/index.d.ts","import":"./dist/testing/index.js","default":"./dist/testing/index.js"}},"dependencies":{"@amritk/mini-lynx-native":"0.2.2"},"peerDependencies":{"typescript":"^5"},"peerDependenciesMeta":{"typescript":{"optional":true}},"devDependencies":{"typescript":"^5"},"gitHead":"c2516a9f0a935e9e8e1eda316d651ac48e0ccc71","_id":"@amritk/lynx-secure-storage@0.2.0","_nodeVersion":"24.19.0","_npmVersion":"12.0.2","dist":{"integrity":"sha512-TbzvtTA4M+Ox+yaJNtLjDFKcawKjFMZ42ybt7LlZ1OnC1ZSlGNQFnblqKJAgBKSaKAui/hgEc0XzR+HQVSEV7Q==","shasum":"8f32c2054abf7cb3c2857e29f159f945a9faa358","tarball":"https://registry.npmjs.org/@amritk/lynx-secure-storage/-/lynx-secure-storage-0.2.0.tgz","fileCount":53,"unpackedSize":142850,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@amritk%2flynx-secure-storage@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIECZgtqKZ1P+P32jcU+jka+g9xAQovtS0gc574S6hq/fAiBBujzFiWzH1DtnN7vuNFXGZ10BwF/IcM0cKHO2REUOAw=="}]},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:0e898257-c1fa-49b8-b993-0da7ff0f6b3a"}},"directories":{},"maintainers":[{"name":"amritk","email":"amrit+spam@hockey-community.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/lynx-secure-storage_0.2.0_1787514356639_0.8188005708754824"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-07T05:59:06.650Z","modified":"2026-08-23T19:45:57.098Z","0.1.0":"2026-08-07T05:59:06.971Z","0.2.0":"2026-08-23T19:45:56.786Z"},"bugs":{"url":"https://github.com/amritk/mini/issues"},"author":{"name":"amritk"},"license":"MIT","homepage":"https://github.com/amritk/mini/tree/main/packages/lynx-secure-storage#readme","keywords":["lynx","secure-storage","keychain","keystore","encrypted-shared-preferences","token","native-module","typescript","mjst"],"repository":{"type":"git","url":"git+https://github.com/amritk/mini.git","directory":"packages/lynx-secure-storage"},"description":"Encrypted key-value storage for Lynx: Keychain on iOS, EncryptedSharedPreferences on Android, and a promise-shaped facade that reaches them from the main thread.","maintainers":[{"name":"amritk","email":"amrit+spam@hockey-community.com"}],"readme":"# @amritk/lynx-secure-storage\n\n**Encrypted key-value storage for Lynx.** The iOS Keychain, Android's\n`EncryptedSharedPreferences` over a Keystore-backed master key, and a\npromise-shaped facade that reaches them from a main-thread\n[`@amritk/mini-lynx`](../mini-lynx) tree.\n\n```ts\nimport { getSecureItem, removeSecureItem, setSecureItem } from '@amritk/lynx-secure-storage'\n\nconst written = await setSecureItem('session', token)\nif (!written.ok) console.warn('not persisted', written.error)\n\nconst session = await getSecureItem('session')   // the string, or null\nawait removeSecureItem('session')                // signing out\n```\n\n## Why this exists\n\nLynx ships no storage of any kind. `@lynx-js/types` declares no key-value API on\nthe `lynx` global — not a secure one, not an insecure one. The one published\noption is TikTok's\n[`sparkling-storage`](https://www.npmjs.com/package/sparkling-storage), and it\nis neither secure nor complete:\n\n- **Android** is plain `SharedPreferences` — unencrypted XML in the app's data\n  directory, and swept into Android's auto-backup by default.\n- **iOS** ships **no implementation at all**: only a `StorageService` protocol\n  resolved out of a DI registry that the host app is expected to fill in.\n\nSo there is currently nothing in the Lynx ecosystem you can put a credential in.\nThat is a problem the moment an app has one — a session token in an engine that\nmay have no cookie jar has to live *somewhere*, and \"somewhere\" should not be a\nfile anyone with the device can read.\n\n## Install\n\n```sh\nbun add @amritk/lynx-secure-storage\n```\n\n`@amritk/mini-lynx-native` comes with it — it is the wire every call travels.\n\n## JavaScript setup\n\nOne line, in your **background** chunk:\n\n```ts\nimport { installNativeBridge } from '@amritk/mini-lynx-native/background'\n\ninstallNativeBridge()\n```\n\nWithout it every call queues forever and nothing says why: `NativeModules` lives\nonly in Lynx's background context, and a `@amritk/mini-lynx` tree renders on the\nmain thread.\n\n## Host app setup\n\nThe native halves link themselves — `lynx.lib.json` declares the Android and iOS\nsources and Lynx's autolinking picks them up. There is **no permission to\nrequest and no `Info.plist` key to add**: neither platform gates a keychain item\nor a Keystore key.\n\nThere is one thing to know about, on Android, and it is a build error rather\nthan something you can forget: see below.\n\n## The surface\n\n```ts\ngetSecureItem(key: string): Promise<string | null>\nsetSecureItem(key: string, value: string, options?: SecureItemOptions): Promise<SecureWriteResult>\nremoveSecureItem(key: string): Promise<void>\nhasSecureItem(key: string): Promise<boolean>\nclearSecureStorage(): Promise<void>\nisSecureStorageAvailable(): Promise<boolean>\n\ntype SecureItemOptions = {\n  /** iOS only. Default 'afterFirstUnlock' — readable by background work after a reboot. */\n  accessibility?: 'whenUnlocked' | 'afterFirstUnlock'\n}\n\ntype SecureWriteResult =\n  | { ok: true }\n  | { ok: false; error: 'unavailable' | 'keystoreFailure' | 'tooLarge'; message: string }\n```\n\n**Strings only, not arbitrary JSON.** The Keychain stores `Data` and\n`EncryptedSharedPreferences` stores a `String`; serialising is yours, and it\nkeeps the contract something you can read off the type.\n\n`MAX_VALUE_BYTES` (8 KiB) is exported too: a value over it answers `tooLarge`\nrather than being truncated.\n\n## `null` means absent. A failed read throws.\n\nThis is the one thing to take away from this page.\n\n`setSecureItem` reports failure as a value, because an app that has just been\nhanded a credential has to branch on whether it was persisted. **Everything else\nthrows when the store cannot be reached**, and that asymmetry is deliberate:\n`getSecureItem` returns `string | null`, so if a broken store also answered\n`null`, an app would sign a user out because a device happened to be locked —\nwith the credential sitting on the disk, intact.\n\n```ts\ntry {\n  const token = await getSecureItem('session')\n  if (token === null) showSignIn()   // genuinely not there\n  else resume(token)\n} catch {\n  showRetry()                        // unknown, which is not the same thing\n}\n```\n\n`isSecureStorageAvailable()` never throws, and is the version to use when you\nwould rather ask than catch.\n\n## What is actually protecting the value\n\n**iOS.** `kSecClassGenericPassword` items under a `kSecAttrService` of the app's\nbundle identifier, so a host app's own Keychain items are never in scope.\n`afterFirstUnlock` maps to `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`\nand `whenUnlocked` to `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. The\n`ThisDeviceOnly` suffix is the load-bearing part: without it an item rides out\nthrough iCloud Keychain and encrypted backups. There is no option here that\nturns that on.\n\nA write to an existing key uses `SecItemUpdate` rather than a delete followed by\nan add. Between a delete and an add there is a window in which the credential\ndoes not exist, and a process killed inside it — which is exactly where the\nsystem kills apps — leaves a user signed out with nothing to explain it.\n\n**Android.** `EncryptedSharedPreferences` (AES256-SIV for keys, AES256-GCM for\nvalues) over a `MasterKey` in the AndroidKeyStore, `minSdk 23` because below\nthat there is no Keystore-backed AES to build on. The master key never leaves\nthe device.\n\n## Android backup: the one thing the host has to know\n\nBecause the master key never leaves the device, a backed-up copy of the\npreferences file is **undecryptable ciphertext** on any other device — which is\n*worse* than the file being absent, because it reads as corruption rather than\nas a signed-out user. Auto-backup is on by default for every app targeting API\n23 and up, so a library that only documented this would be shipping that failure\nto most of its hosts.\n\nSo this library contributes the exclusion through its own manifest:\n\n```xml\n<application\n  android:fullBackupContent=\"@xml/minilynx_secure_storage_backup_rules\"\n  android:dataExtractionRules=\"@xml/minilynx_secure_storage_data_extraction_rules\" />\n```\n\nIf your app declares either attribute itself, the manifest merger will **fail\nthe build** naming both — which is the intended outcome, because which rules win\nis your app's decision and a build error is the only way to make it one. Resolve\nit in your own manifest:\n\n```xml\n<application\n  android:fullBackupContent=\"@xml/app_backup_rules\"\n  android:dataExtractionRules=\"@xml/app_data_extraction_rules\"\n  tools:replace=\"android:fullBackupContent,android:dataExtractionRules\">\n```\n\nand copy these two entries into your own rule files:\n\n```xml\n<!-- res/xml/app_backup_rules.xml -->\n<full-backup-content>\n  <exclude domain=\"sharedpref\" path=\"minilynx_secure_storage.xml\" />\n</full-backup-content>\n```\n\n```xml\n<!-- res/xml/app_data_extraction_rules.xml -->\n<data-extraction-rules>\n  <cloud-backup>\n    <exclude domain=\"sharedpref\" path=\"minilynx_secure_storage.xml\" />\n  </cloud-backup>\n  <device-transfer>\n    <exclude domain=\"sharedpref\" path=\"minilynx_secure_storage.xml\" />\n  </device-transfer>\n</data-extraction-rules>\n```\n\n`device-transfer` is the phone-to-phone copy a user does when setting up a new\ndevice. It never touches a cloud, which makes it sound harmless — but the\nKeystore key does not travel with it either, so the outcome is the same.\n\nAn app with `android:allowBackup=\"false\"` needs none of this.\n\n## The things that will surprise you\n\n**Keychain items survive an uninstall.** Delete an app on iOS, install it again,\nand its Keychain items are still there — so a reinstall can find a live session\nbelonging to an install the user deliberately removed. This package handles it:\nthe iOS half keeps a first-run flag in `NSUserDefaults`, which *is* cleared on\nuninstall, and clears the store when it finds the flag missing. A fresh install\nis a fresh store, and you do not have to remember to ask.\n\n**An Android keyset can become unreadable.** After a restore, or on devices that\ndrop Keystore keys when the lock screen changes, `EncryptedSharedPreferences`\nthrows — on `create`, which for most apps is on the path to the first screen.\nThis package discards the file and the key and recreates the pair rather than\npropagating that: the data is unrecoverable either way, and the only question\nwas whether the app opens. Your user is signed out; your app is not in a crash\nloop.\n\n**A read before the first unlock fails.** `errSecInteractionNotAllowed`, on iOS,\nfor anything written as `whenUnlocked`. That is exactly why the default is\n`afterFirstUnlock` — background work after a reboot has to be able to read a\nsession token.\n\n**Nothing is ever logged.** Not the key, not the value, at any level, on either\nplatform. A failure names its `OSStatus` or its exception class and nothing else,\nand the parity suite fails the build if a logging call appears in either native\nhalf.\n\n## Not in 0.1: biometric-gated items\n\nNo `LAContext` on iOS and no `setUserAuthenticationRequired` on Android. Putting\nFace ID or a fingerprint in front of an item means a prompt, a lifecycle around\nit, a cancellation path and its own failure modes on each platform — and half of\nthat shipped would be worse than none of it, because an app would wire a\ncredential to a gate that only really works on one platform.\n\n## Testing without a device\n\n`@amritk/lynx-secure-storage/testing` exports the native module's contract as\nsomething a test can drive:\n\n```ts\nimport { MODULE } from '@amritk/lynx-secure-storage'\nimport { createFakeSecureStorage } from '@amritk/lynx-secure-storage/testing'\n\nconst storage = createFakeSecureStorage()\ninstallNativeBridge({ peer, emitter, modules: { [MODULE]: storage.module } })\n\nawait setSecureItem('session', 'token')\nstorage.reinstall()\nexpect(await getSecureItem('session')).toBeNull()\n```\n\nIt reproduces the platforms rather than smoothing them over, and the states it\ncan stage are the ones a device will not produce on request:\n`setAvailable(false)` is a store that does not answer, `setDeviceLocked(true)`\nwithholds `whenUnlocked` items only, `corruptKeyset()` recovers by discarding\neverything, `breakKeystore(true)` does not recover, and `restartProcess()`\ndiffers from `reinstall()` in exactly the one way that matters — a reinstall\nclears `NSUserDefaults`, and that is what makes the previous install's session\ndisappear.\n\n## No signals here\n\nThe surface is promises, deliberately. A second edge onto the signal engine is\nhow a consumer ends up with two reactive graphs that cannot see each other's\nwrites, and wiring a promise into whichever graph you already have is one line.\nIt also means this works unchanged from ReactLynx, Vue Lynx, or plain\nbackground-thread code.\n\n## What is verified, and what is not\n\nThe facade and the fake run here. `bun run check:android` compiles the Kotlin\nagainst the real Lynx AAR and `src/native-contract.test.ts` pins the method\nsurfaces, the error codes, the Keychain protection classes, the backup exclusion\nand the no-logging rule against each other. `pod lib lint` compiles the\nObjective-C against the real iOS SDK, but it is no longer part of CI — run it by\nhand on a Mac.\n\n**None of that is a device, and this is the package where that caveat costs the\nmost.** Nothing here has proved that a Keychain item survives a real uninstall,\nthat the Android recovery fires on a real restored backup, or that a\n`whenUnlocked` item is genuinely unreadable on a locked screen. See\n[`AGENTS.md`](./AGENTS.md) for exactly what each check does and does not cover.\n\n## License\n\nMIT\n","readmeFilename":"README.md"}