feat: add encryption key bridge for nodejs-mobile local mode
- Add src/crypto.ts: generates 48-byte hex key, stores via capacitor-secure-storage-plugin (Android Keystore / iOS Keychain) - Add nodejs-assets/nodejs-project/main.js: waits for encryption key from WebView before starting server - Update LoadingScreen.tsx: sends encryption key to embedded Node process over nodejs-mobile-cordova channel - Add capacitor-secure-storage-plugin dependency - Update vite-env.d.ts with channel types for nodejs - Add private docs to .gitignore (PROJECT.md, PLAN.md, etc.)
This commit is contained in:
parent
186d651862
commit
e1f63a8215
|
|
@ -14,3 +14,11 @@ Thumbs.db
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Private project/agent docs — never commit
|
||||||
|
PROJECT.md
|
||||||
|
PLAN.md
|
||||||
|
FUTURE.md
|
||||||
|
HISTORY.md
|
||||||
|
DEVELOPMENT_LOG.md
|
||||||
|
.learnings/
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Server-side paths, relative to this directory (the app's writable storage
|
||||||
|
// on-device — nodejs-mobile-cordova copies nodejs-project here at runtime).
|
||||||
|
process.env.DB_PATH = path.join(__dirname, 'data', 'bills.db');
|
||||||
|
process.env.BACKUP_PATH = path.join(__dirname, 'backups');
|
||||||
|
process.env.PORT = process.env.PORT || '3000';
|
||||||
|
process.env.BIND_HOST = '127.0.0.1';
|
||||||
|
|
||||||
|
const cordova = require('cordova-bridge');
|
||||||
|
|
||||||
|
// Wait for the WebView to hand over the device-bound encryption key (stored
|
||||||
|
// in Android Keystore / iOS Keychain — see src/crypto.ts) before starting the
|
||||||
|
// server, so encryptionService.js picks up TOKEN_ENCRYPTION_KEY on first use.
|
||||||
|
cordova.channel.on('message', function (msg) {
|
||||||
|
if (msg && msg.type === 'encryptionKey' && typeof msg.key === 'string') {
|
||||||
|
process.env.TOKEN_ENCRYPTION_KEY = msg.key;
|
||||||
|
require('./server/server.js');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cordova.channel.post('message', { type: 'ready' });
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"@capacitor/core": "^8.4.0",
|
"@capacitor/core": "^8.4.0",
|
||||||
"@capacitor/ios": "^8.4.0",
|
"@capacitor/ios": "^8.4.0",
|
||||||
"@capacitor/preferences": "^8.0.1",
|
"@capacitor/preferences": "^8.0.1",
|
||||||
|
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||||
"nodejs-mobile-cordova": "^0.4.3",
|
"nodejs-mobile-cordova": "^0.4.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getOrCreateEncryptionKey } from './crypto';
|
||||||
|
|
||||||
const LOCAL_URL = 'http://localhost:3000';
|
const LOCAL_URL = 'http://localhost:3000';
|
||||||
const HEALTH_URL = `${LOCAL_URL}/api/health`;
|
const HEALTH_URL = `${LOCAL_URL}/api/health`;
|
||||||
|
|
@ -44,6 +45,20 @@ export default function LoadingScreen({ onReady }: Props) {
|
||||||
setError('Failed to start local server: ' + String(err));
|
setError('Failed to start local server: ' + String(err));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Hand the device-bound encryption key to the embedded Node process over
|
||||||
|
// the nodejs-mobile-cordova channel. main.js waits for this message
|
||||||
|
// before requiring server.js, so encryption is configured from the
|
||||||
|
// first request. The listener is registered synchronously to avoid
|
||||||
|
// missing main.js's 'ready' message; the key itself is fetched async.
|
||||||
|
window.nodejs.channel.on('message', msg => {
|
||||||
|
if (cancelled || (msg as { type?: string })?.type !== 'ready') return;
|
||||||
|
getOrCreateEncryptionKey().then(key => {
|
||||||
|
if (!cancelled) window.nodejs!.channel.post('message', { type: 'encryptionKey', key });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
pollHealth();
|
pollHealth();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { SecureStoragePlugin } from 'capacitor-secure-storage-plugin';
|
||||||
|
|
||||||
|
const KEY_NAME = 'tokenEncryptionKey';
|
||||||
|
const KEY_BYTES = 48;
|
||||||
|
|
||||||
|
function generateHexKey(bytes: number): string {
|
||||||
|
const arr = new Uint8Array(bytes);
|
||||||
|
globalThis.crypto.getRandomValues(arr);
|
||||||
|
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the device's local-mode database encryption key (TOKEN_ENCRYPTION_KEY),
|
||||||
|
* generating and persisting one in secure storage (Android Keystore / iOS Keychain)
|
||||||
|
* on first launch.
|
||||||
|
*/
|
||||||
|
export async function getOrCreateEncryptionKey(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { value } = await SecureStoragePlugin.get({ key: KEY_NAME });
|
||||||
|
if (value) return value;
|
||||||
|
} catch {
|
||||||
|
// Not found — fall through to generate one.
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = generateHexKey(KEY_BYTES);
|
||||||
|
await SecureStoragePlugin.set({ key: KEY_NAME, value: key });
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
@ -4,5 +4,9 @@
|
||||||
interface Window {
|
interface Window {
|
||||||
nodejs?: {
|
nodejs?: {
|
||||||
start: (filename: string, callback?: (err: unknown) => void) => void;
|
start: (filename: string, callback?: (err: unknown) => void) => void;
|
||||||
|
channel: {
|
||||||
|
on: (event: string, callback: (msg: unknown) => void) => void;
|
||||||
|
post: (event: string, message: unknown) => void;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue