@capgo/capgo-skills
Version:
55 agent skills for Capacitor mobile development
88 lines (65 loc) • 5.53 kB
Markdown
# Secrets and On-Device Storage
## Secrets in the app
Everything shipped in the IPA/APK is readable: the web bundle (`dist/`, `www/`), `capacitor.config.json` (copied into `android/app/src/main/assets/` and `ios/App/App/`), `Info.plist`, `strings.xml`, and native constants.
Treat these as public:
- Bundler env values (`import.meta.env.VITE_*`, `process.env.NEXT_PUBLIC_*`, Angular `environment.ts`). They are inlined at build time.
- Values set under `plugins.*` in `capacitor.config.*`, including `@capgo/capacitor-env` keys. That plugin is for per-build, non-secret configuration (white label, feature flags). It moves values out of the JS bundle, but they stay inside the app package.
- Live update bundles uploaded without encryption (see platform-hardening.md).
Acceptable in the client: publishable/anon keys designed for public use (Stripe publishable key, Firebase web config, Supabase anon key with row-level security on). Never in the client: Stripe secret keys, Supabase `service_role`, OpenAI/Anthropic API keys, AWS secret keys, database credentials, signing keys, webhook secrets.
Fix pattern: move the call behind a backend endpoint (serverless function or API) that holds the secret and authenticates the user.
```typescript
// Before: secret shipped to every device
await fetch('https://api.openai.com/v1/responses', {
headers: { Authorization: `Bearer ${import.meta.env.VITE_OPENAI_KEY}` },
});
// After: the app calls its own backend with the user's session
await fetch('https://api.example.com/ai/complete', {
method: 'POST',
headers: { Authorization: `Bearer ${sessionToken}` },
body: JSON.stringify({ prompt }),
});
```
If a secret already shipped, rotating it is mandatory: removing it from the next build does not revoke copies in installed apps. Tell the user which keys to rotate.
Also check: `.env` committed to git (`git ls-files | grep -E '^\.env'`), keystore passwords in `capacitor.config.*` `android.buildOptions` (use CI secrets instead), `google-services.json` / `GoogleService-Info.plist` are fine to ship but should match the right project.
## Token and credential storage
| Data | Store in | Never in |
|------|----------|----------|
| Refresh tokens, long-lived session tokens, encryption keys | Keychain / Keystore via `@capgo/capacitor-native-biometric` `setData` (8.6.0+) or `setCredentials` | `@capacitor/preferences`, `localStorage`, IndexedDB, plain SQLite, files |
| Short-lived access tokens | Memory only; refresh from the secure store | Persistent web storage |
| Username + password for biometric re-login | `setCredentials` with `accessControl` | Preferences |
| Non-sensitive settings (theme, onboarding flags) | `@capacitor/preferences` | n/a |
| Sensitive bulk data | Encrypted SQLite (check the plugin's encryption support) with the key in Keychain/Keystore | Unencrypted DB |
`@capacitor/preferences` uses `UserDefaults` (iOS) and `SharedPreferences` (Android) without encryption. `localStorage` lives in WebView storage on disk. Both are extractable from backups or a rooted/jailbroken device.
### `@capgo/capacitor-native-biometric`
```typescript
import { NativeBiometric, AccessControl } from '@capgo/capacitor-native-biometric';
// Encrypted at rest (Keychain / Keystore), no prompt to read
await NativeBiometric.setData({ key: 'refresh_token', value: refreshToken });
const { value } = await NativeBiometric.getData({ key: 'refresh_token' });
// Hardware-bound: reading requires a live biometric check
await NativeBiometric.setData({
key: 'refresh_token',
value: refreshToken,
accessControl: AccessControl.BIOMETRY_CURRENT_SET,
});
const secure = await NativeBiometric.getSecureData({
key: 'refresh_token',
reason: 'Unlock your account',
});
await NativeBiometric.deleteData({ key: 'refresh_token' }); // on logout
```
Rules:
- `getData()` only returns values stored without `accessControl`; protected values need `getSecureData()` / `getSecureCredentials()`.
- `BIOMETRY_CURRENT_SET` invalidates the item when the user enrolls a new fingerprint/face. Handle error code `21` by re-authenticating against the server.
- On Android, `authValidityDuration > 0` lets any code read the key for that window without a prompt. Keep `0` (default) for high-value secrets.
- `verifyIdentity()` alone is a UI gate, not a security boundary. It can be hooked on compromised devices. Bind the secret to `accessControl` and verify sessions server-side.
- `isAvailable()` returns `{ isAvailable, biometryType, ... }`. Use `isAvailable` for logic; `biometryType` is for display.
## Logging
- Remove `console.log` of tokens, passwords, full API responses, and PII. iOS `os_log` and Android `logcat` output is readable by anyone with the device attached.
- `loggingBehavior` controls native logs and `console.*` statements Capacitor redirects to Xcode / Logcat. Default `'debug'` logs only in debug builds; `'none'` disables them. Flag `'production'` in review: it leaks logs on released builds.
- Strip `debugger` statements (capsec DBG001).
## Crypto
- Use Web Crypto (`crypto.subtle`) with AES-GCM and a random 12-byte IV per message (`crypto.getRandomValues`). Never reuse a static IV.
- Never use MD5, SHA-1, DES, 3DES, RC4, or ECB mode for security purposes.
- Generate tokens and nonces with `crypto.getRandomValues` / `crypto.randomUUID`, never `Math.random`.
- Never hardcode the encryption key. Generate it on device, store it in Keychain/Keystore, or derive it from a user secret with PBKDF2 (high iteration count) or Argon2 on the server.