diff --git a/.github/scripts/android-e2e.sh b/.github/scripts/android-e2e.sh
index 6c0e96e2..c7daf80e 100644
--- a/.github/scripts/android-e2e.sh
+++ b/.github/scripts/android-e2e.sh
@@ -4,10 +4,18 @@ set -euo pipefail
trap 'adb logcat -d > "$GITHUB_WORKSPACE/android-logcat.txt" || true' EXIT
cd "$GITHUB_WORKSPACE/android"
-./gradlew :app:assembleRelease -PreactNativeArchitectures=x86_64 --no-daemon
-APK_PATH="$(find app/build/outputs/apk/release -maxdepth 1 -name '*.apk' -print -quit)"
+./gradlew \
+ :app:assembleDebug \
+ -PRING_BUNDLE_DEBUG_JS=true \
+ -PreactNativeArchitectures=x86_64 \
+ --no-daemon
+APK_PATH="$(find app/build/outputs/apk/debug -maxdepth 1 -name '*.apk' -print -quit)"
if [[ -z "$APK_PATH" ]]; then
- echo "No release APK found in app/build/outputs/apk/release" >&2
+ echo "No debug APK found in app/build/outputs/apk/debug" >&2
+ exit 1
+fi
+if ! unzip -tqq "$APK_PATH" assets/index.android.bundle >/dev/null; then
+ echo "Debug E2E APK is missing its packaged JavaScript bundle" >&2
exit 1
fi
adb install -r "$APK_PATH"
@@ -25,4 +33,4 @@ INVITE_CODE_COMPACT="${INVITE_CODE//-/}"
echo "::add-mask::$INVITE_CODE"
echo "::add-mask::$INVITE_CODE_COMPACT"
-maestro --platform=android test -e APP_ID=to.pubky.ring -e INVITE_CODE="$INVITE_CODE" .maestro
+maestro --platform=android test -e APP_ID=app.pubkyring -e INVITE_CODE="$INVITE_CODE" .maestro
diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml
index e68c5b4c..f472ad7a 100644
--- a/.github/workflows/android-e2e.yml
+++ b/.github/workflows/android-e2e.yml
@@ -75,8 +75,8 @@ jobs:
uses: actions/upload-artifact@v7
if: always()
with:
- name: android-release-apk
- path: android/app/build/outputs/apk/release/*.apk
+ name: android-debug-apk
+ path: android/app/build/outputs/apk/debug/*.apk
if-no-files-found: ignore
retention-days: 7
diff --git a/.maestro/scripts/run-local.sh b/.maestro/scripts/run-local.sh
index fbaeed0d..d2fece75 100755
--- a/.maestro/scripts/run-local.sh
+++ b/.maestro/scripts/run-local.sh
@@ -6,7 +6,7 @@ flow="${2:-.maestro}"
case "$platform" in
android)
- app_id="to.pubky.ring"
+ app_id="app.pubkyring"
;;
ios)
app_id="app.pubkyring"
diff --git a/App.tsx b/App.tsx
index 2153ce24..97eb9cf1 100644
--- a/App.tsx
+++ b/App.tsx
@@ -15,6 +15,7 @@ import { updateIsOnline } from './src/store/slices/settingsSlice.ts';
import { checkNetworkConnection } from './src/utils/helpers.ts';
import { setDeepLink } from './src/store/slices/pubkysSlice.ts';
import { parseInput } from './src/utils/inputParser.ts';
+import { SharedPubkyDiscoveryContext, useSharedPubkyDiscovery } from './src/hooks/useSharedPubkyDiscovery.ts';
import './src/theme/toast';
function App(): React.JSX.Element {
@@ -25,6 +26,7 @@ function App(): React.JSX.Element {
isOnlineRef.current = isOnline;
const dispatch = useDispatch();
const { t } = useTranslation();
+ const sharedPubkyDiscovery = useSharedPubkyDiscovery();
// Handle deep linking
useEffect(() => {
@@ -116,7 +118,9 @@ function App(): React.JSX.Element {
-
+
+
+
diff --git a/__tests__/App.test.tsx b/__tests__/App.test.tsx
index a67f99c6..6cd75101 100644
--- a/__tests__/App.test.tsx
+++ b/__tests__/App.test.tsx
@@ -67,8 +67,7 @@ jest.mock('react-native-safe-area-context', () => {
__esModule: true,
SafeAreaProvider: ({ children }: { children?: ReactNode }) =>
ReactMock.createElement(View, null, children),
- SafeAreaView: ({ children }: { children?: ReactNode }) =>
- ReactMock.createElement(View, null, children),
+ SafeAreaView: ({ children }: { children?: ReactNode }) => ReactMock.createElement(View, null, children),
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
useSafeAreaFrame: () => ({ x: 0, y: 0, width: 320, height: 640 }),
};
@@ -80,6 +79,17 @@ jest.mock('@synonymdev/react-native-toast', () => ({
showToast: jest.fn(),
}));
+jest.mock('../src/hooks/useSharedPubkyDiscovery.ts', () => {
+ const ReactMock = require('react');
+ const value = { available: false, identities: [], refresh: jest.fn() };
+
+ return {
+ __esModule: true,
+ SharedPubkyDiscoveryContext: ReactMock.createContext(value),
+ useSharedPubkyDiscovery: () => value,
+ };
+});
+
jest.mock('react-i18next', () => ({
__esModule: true,
useTranslation: () => ({
diff --git a/__tests__/pubkyIdentityLifecycle.test.ts b/__tests__/pubkyIdentityLifecycle.test.ts
new file mode 100644
index 00000000..f0ba749a
--- /dev/null
+++ b/__tests__/pubkyIdentityLifecycle.test.ts
@@ -0,0 +1,197 @@
+import { err, ok } from '@synonymdev/result';
+import { EBackupPreference, Pubky } from '../src/types/pubky';
+import { deletePubky, reconcileOwnedSharedPubkys, savePubky } from '../src/utils/pubky';
+
+const OWNED = 'ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy';
+const SECRET = '0123456789abcdef'.repeat(4);
+
+const mockGetPublicKeyFromSecretKey = jest.fn();
+const mockGetKeychainValue = jest.fn();
+const mockSetKeychainValue = jest.fn();
+const mockResetKeychainValue = jest.fn();
+const mockGetAllKeychainKeys = jest.fn();
+const mockGetPubkyDataFromStore = jest.fn();
+const mockMirrorSharedPubky = jest.fn();
+const mockRemoveSharedPubky = jest.fn();
+const mockReconcileSharedPubkys = jest.fn();
+
+jest.mock('@synonymdev/react-native-pubky', () => ({
+ auth: jest.fn(),
+ generateMnemonicPhraseAndKeypair: jest.fn(),
+ get: jest.fn(),
+ getHomeserver: jest.fn(),
+ getPublicKeyFromSecretKey: (...args: unknown[]) => mockGetPublicKeyFromSecretKey(...args),
+ getSignupToken: jest.fn(),
+ mnemonicPhraseToKeypair: jest.fn(),
+ republishHomeserver: jest.fn(),
+ signIn: jest.fn(),
+ signOut: jest.fn(),
+ signUp: jest.fn(),
+}));
+
+jest.mock('@synonymdev/react-native-toast', () => ({ showToast: jest.fn() }));
+
+jest.mock('../src/i18n', () => ({
+ __esModule: true,
+ default: { t: (key: string) => key },
+}));
+
+jest.mock('../src/store', () => ({ store: { dispatch: jest.fn() } }));
+
+jest.mock('../src/store/slices/pubkysSlice', () => ({
+ addProcessing: (payload: unknown) => ({ type: 'pubky/addProcessing', payload }),
+ addPubky: (payload: unknown) => ({ type: 'pubky/addPubky', payload }),
+ addSession: (payload: unknown) => ({ type: 'pubky/addSession', payload }),
+ removeProcessing: (payload: unknown) => ({ type: 'pubky/removeProcessing', payload }),
+ removePubky: (payload: unknown) => ({ type: 'pubky/removePubky', payload }),
+ removeSession: (payload: unknown) => ({ type: 'pubky/removeSession', payload }),
+ setHomeserver: (payload: unknown) => ({ type: 'pubky/setHomeserver', payload }),
+ setPubkyData: (payload: unknown) => ({ type: 'pubky/setPubkyData', payload }),
+ setSignedUp: (payload: unknown) => ({ type: 'pubky/setSignedUp', payload }),
+}));
+
+jest.mock('../src/utils/helpers.ts', () => ({ checkNetworkConnection: jest.fn() }));
+
+jest.mock('../src/utils/store-helpers.ts', () => ({
+ getPubkyDataFromStore: (...args: unknown[]) => mockGetPubkyDataFromStore(...args),
+}));
+
+jest.mock('../src/utils/keychain', () => ({
+ getAllKeychainKeys: (...args: unknown[]) => mockGetAllKeychainKeys(...args),
+ getKeychainValue: (...args: unknown[]) => mockGetKeychainValue(...args),
+ resetKeychainValue: (...args: unknown[]) => mockResetKeychainValue(...args),
+ setKeychainValue: (...args: unknown[]) => mockSetKeychainValue(...args),
+}));
+
+jest.mock('../src/utils/sharedPubky.ts', () => {
+ const normalize = (value: unknown): string | undefined => {
+ if (typeof value !== 'string') return undefined;
+ const bare = value.startsWith('pubky') ? value.slice(5) : value;
+ return /^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$/.test(bare) ? bare : undefined;
+ };
+
+ return {
+ BITKIT_SOURCE_APP: 'to.bitkit',
+ RING_SOURCE_APP: 'app.pubkyring',
+ getSharedPubkyCredential: jest.fn(),
+ isValidSharedSecretKey: (value: unknown) =>
+ typeof value === 'string' && /^[0-9a-f]{64}$/.test(value),
+ mirrorSharedPubky: (...args: unknown[]) => mockMirrorSharedPubky(...args),
+ normalizeSharedPubky: normalize,
+ privatePubkyService: (service: string) => {
+ const pubky = normalize(service);
+ return pubky ? { service, pubky } : undefined;
+ },
+ reconcileSharedPubkys: (...args: unknown[]) => mockReconcileSharedPubkys(...args),
+ removeSharedPubky: (...args: unknown[]) => mockRemoveSharedPubky(...args),
+ withPubkyIdentityLifecycle: (operation: () => Promise) => operation(),
+ };
+});
+
+const ringPubky = (): Pubky => ({
+ name: '',
+ homeserver: '',
+ signedUp: false,
+ signupToken: '',
+ image: '',
+ sessions: [],
+ backupPreference: EBackupPreference.unknown,
+ isBackedUp: false,
+ sourceApp: 'app.pubkyring',
+});
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetPublicKeyFromSecretKey.mockResolvedValue(ok({ public_key: OWNED }));
+ mockGetKeychainValue.mockResolvedValue(ok(JSON.stringify({ secretKey: SECRET, mnemonic: '' })));
+ mockSetKeychainValue.mockResolvedValue(ok('saved'));
+ mockResetKeychainValue.mockResolvedValue(ok(true));
+ mockGetAllKeychainKeys.mockResolvedValue([]);
+ mockGetPubkyDataFromStore.mockReturnValue(undefined);
+ mockMirrorSharedPubky.mockResolvedValue(true);
+ mockRemoveSharedPubky.mockResolvedValue(true);
+ mockReconcileSharedPubkys.mockResolvedValue(true);
+});
+
+test('re-imports an existing Ring identity instead of rejecting it as a duplicate', async () => {
+ mockGetPubkyDataFromStore.mockImplementation((pubky: string) => (pubky === OWNED ? ringPubky() : undefined));
+ mockGetAllKeychainKeys.mockResolvedValue([OWNED]);
+ const dispatch = jest.fn();
+
+ const result = await savePubky({
+ secretKey: SECRET,
+ pubky: OWNED,
+ dispatch,
+ isBackedUp: true,
+ backupPreference: EBackupPreference.encryptedFile,
+ });
+
+ expect(result.isOk()).toBe(true);
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'pubky/setPubkyData',
+ payload: expect.objectContaining({ pubky: OWNED }),
+ }),
+ );
+ expect(mockMirrorSharedPubky).toHaveBeenCalledWith(OWNED, SECRET);
+});
+
+test('never promotes a Bitkit-owned identity into Ring private storage', async () => {
+ mockGetPubkyDataFromStore.mockReturnValue({ ...ringPubky(), sourceApp: 'to.bitkit' });
+ const dispatch = jest.fn();
+
+ const result = await savePubky({ secretKey: SECRET, pubky: OWNED, dispatch });
+
+ expect(result.isErr()).toBe(true);
+ expect(mockSetKeychainValue).not.toHaveBeenCalled();
+ expect(dispatch).not.toHaveBeenCalled();
+});
+
+test('rolls back a newly written private record when verification fails', async () => {
+ mockGetKeychainValue.mockResolvedValue(err(new Error('read failed')));
+ const dispatch = jest.fn();
+
+ const result = await savePubky({ secretKey: SECRET, pubky: OWNED, dispatch });
+
+ expect(result.isErr()).toBe(true);
+ expect(mockResetKeychainValue).toHaveBeenCalledWith({ key: OWNED });
+ expect(dispatch).not.toHaveBeenCalled();
+});
+
+test('does not prune shared mirrors after a private keychain read failure', async () => {
+ mockGetAllKeychainKeys.mockResolvedValue([OWNED]);
+ mockGetKeychainValue.mockResolvedValue(err(new Error('temporarily unavailable')));
+
+ await expect(reconcileOwnedSharedPubkys()).resolves.toBe(false);
+ expect(mockReconcileSharedPubkys).not.toHaveBeenCalled();
+});
+
+test('deletes every private service for a normalized identity before removing Redux state', async () => {
+ mockGetPubkyDataFromStore.mockImplementation((pubky: string) => (pubky === OWNED ? ringPubky() : undefined));
+ mockGetAllKeychainKeys.mockResolvedValue([OWNED, `pubky${OWNED}`]);
+ const dispatch = jest.fn();
+
+ const result = await deletePubky(`pk:${OWNED}`, dispatch);
+
+ expect(result.isOk()).toBe(true);
+ expect(mockRemoveSharedPubky).toHaveBeenCalledWith(OWNED);
+ expect(mockResetKeychainValue).toHaveBeenCalledTimes(2);
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'pubky/removePubky', payload: OWNED }),
+ );
+});
+
+test('disconnects a Bitkit identity without deleting either key store', async () => {
+ mockGetPubkyDataFromStore.mockReturnValue({ ...ringPubky(), sourceApp: 'to.bitkit' });
+ const dispatch = jest.fn();
+
+ const result = await deletePubky(OWNED, dispatch);
+
+ expect(result.isOk()).toBe(true);
+ expect(mockRemoveSharedPubky).not.toHaveBeenCalled();
+ expect(mockResetKeychainValue).not.toHaveBeenCalled();
+ expect(mockGetAllKeychainKeys).not.toHaveBeenCalled();
+ expect(dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({ type: 'pubky/removePubky', payload: OWNED }),
+ );
+});
diff --git a/__tests__/sharedPubky.test.ts b/__tests__/sharedPubky.test.ts
new file mode 100644
index 00000000..110b9e13
--- /dev/null
+++ b/__tests__/sharedPubky.test.ts
@@ -0,0 +1,214 @@
+import { NativeModules, Platform } from 'react-native';
+import {
+ BITKIT_SOURCE_APP,
+ canonicalSharedPubky,
+ clearOwnedSharedPubkys,
+ discoverSharedPubkys,
+ getPrivateKeychainAccessGroup,
+ getSharedPubkyCredential,
+ isValidSharedSecretKey,
+ mirrorSharedPubky,
+ normalizeSharedPubky,
+ privatePubkyService,
+ reconcileSharedPubkys,
+ removeSharedPubky,
+ withPubkyIdentityLifecycle,
+} from '../src/utils/sharedPubky';
+import { getPublicKeyFromSecretKey } from '@synonymdev/react-native-pubky';
+
+jest.mock('@synonymdev/react-native-pubky', () => ({
+ getPublicKeyFromSecretKey: jest.fn(),
+}));
+
+const OWNED = 'ufibwbmed6jeq9k4p583go95wofakh9fwpp4k734trq79pd9u1uy';
+const SHARED = '8um71us3fyw6h8wbcxb5ar3rwusy1a6u49956ikzojg3gcwd1dty';
+const OTHER = '3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg';
+const PREFIX_LIKE_BARE = `pubky${'y'.repeat(47)}`;
+const SECRET_A = '0123456789abcdef'.repeat(4);
+const SECRET_B = 'abcdef0123456789'.repeat(4);
+const derive = getPublicKeyFromSecretKey as jest.MockedFunction;
+const mirror = jest.fn();
+const remove = jest.fn();
+const reconcile = jest.fn();
+const clear = jest.fn();
+const list = jest.fn();
+const credential = jest.fn();
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ Object.defineProperty(Platform, 'OS', { configurable: true, value: 'android' });
+ NativeModules.SharedPubky = {
+ privateAccessGroup: 'TEAM.app.pubkyring',
+ mirror,
+ remove,
+ reconcile,
+ clear,
+ list,
+ credential,
+ };
+});
+
+test('normalizes only canonical raw or pubky-prefixed z-base32 public keys', () => {
+ expect(normalizeSharedPubky(SHARED)).toBe(SHARED);
+ expect(normalizeSharedPubky(`pubky${SHARED}`)).toBe(SHARED);
+ expect(normalizeSharedPubky(PREFIX_LIKE_BARE)).toBe(PREFIX_LIKE_BARE);
+ expect(normalizeSharedPubky(`pubky${PREFIX_LIKE_BARE}`)).toBe(PREFIX_LIKE_BARE);
+ expect(normalizeSharedPubky(`pk:${SHARED}`)).toBeUndefined();
+ expect(normalizeSharedPubky(SHARED.toUpperCase())).toBeUndefined();
+ expect(normalizeSharedPubky(`${SHARED.slice(0, 51)}0`)).toBeUndefined();
+ expect(normalizeSharedPubky(SHARED.slice(0, 51))).toBeUndefined();
+});
+
+test('accepts only exact bare public keys at the shared wire boundary', () => {
+ expect(canonicalSharedPubky(SHARED)).toBe(SHARED);
+ expect(canonicalSharedPubky(`pubky${SHARED}`)).toBeUndefined();
+ expect(canonicalSharedPubky(` ${SHARED}`)).toBeUndefined();
+ expect(canonicalSharedPubky(`${SHARED} `)).toBeUndefined();
+});
+
+test('keeps the actual private service name separate from its canonical wire pubky', () => {
+ expect(privatePubkyService(OWNED)).toEqual({ service: OWNED, pubky: OWNED });
+ expect(privatePubkyService(`pubky${OWNED}`)).toEqual({
+ service: `pubky${OWNED}`,
+ pubky: OWNED,
+ });
+ expect(privatePubkyService(`pk:${OWNED}`)).toBeUndefined();
+});
+
+test('accepts only canonical lowercase 32-byte hex secret keys at the sharing boundary', async () => {
+ expect(isValidSharedSecretKey(SECRET_A)).toBe(true);
+ expect(isValidSharedSecretKey(SECRET_A.toUpperCase())).toBe(false);
+ expect(isValidSharedSecretKey(SECRET_A.slice(0, 63))).toBe(false);
+ expect(isValidSharedSecretKey(`${SECRET_A.slice(0, 63)}g`)).toBe(false);
+
+ await expect(mirrorSharedPubky(OWNED, SECRET_A.toUpperCase())).resolves.toBe(false);
+ await expect(reconcileSharedPubkys([{ pubky: OWNED, secretKey: 'secret' }])).resolves.toBe(false);
+ expect(mirror).not.toHaveBeenCalled();
+ expect(reconcile).not.toHaveBeenCalled();
+});
+
+test('publishes, reconciles, and removes only canonical source-owned values', async () => {
+ mirror.mockResolvedValue(undefined);
+ reconcile.mockResolvedValue(undefined);
+ remove.mockResolvedValue(undefined);
+ clear.mockResolvedValue(undefined);
+
+ await expect(mirrorSharedPubky(`pubky${OWNED}`, SECRET_A)).resolves.toBe(true);
+ await expect(reconcileSharedPubkys([{ pubky: OWNED, secretKey: SECRET_A }])).resolves.toBe(true);
+ await expect(removeSharedPubky(OWNED)).resolves.toBe(true);
+ await expect(clearOwnedSharedPubkys()).resolves.toBe(true);
+
+ expect(mirror).toHaveBeenCalledWith(OWNED, SECRET_A);
+ expect(reconcile).toHaveBeenCalledWith([{ pubky: OWNED, secretKey: SECRET_A }]);
+ expect(remove).toHaveBeenCalledWith(OWNED);
+ expect(clear).toHaveBeenCalledTimes(1);
+});
+
+test('discovery accepts public metadata only and filters owned, malformed, duplicate, and wrong-source rows', async () => {
+ list.mockResolvedValue({
+ available: true,
+ identities: [
+ { version: 1, sourceApp: BITKIT_SOURCE_APP, pubky: OWNED },
+ { version: 1, sourceApp: BITKIT_SOURCE_APP, pubky: SHARED, secretKey: 'must-be-ignored' },
+ { version: 1, sourceApp: BITKIT_SOURCE_APP, pubky: SHARED },
+ { version: 2, sourceApp: BITKIT_SOURCE_APP, pubky: OTHER },
+ { version: 1, sourceApp: 'app.pubkyring', pubky: OTHER },
+ { version: 1, sourceApp: BITKIT_SOURCE_APP, pubky: 'invalid' },
+ ],
+ });
+
+ await expect(discoverSharedPubkys([OWNED])).resolves.toEqual({
+ available: true,
+ identities: [{ version: 1, sourceApp: BITKIT_SOURCE_APP, pubky: SHARED }],
+ });
+ expect(derive).not.toHaveBeenCalled();
+});
+
+test('retrieves and derives only the selected credential just in time', async () => {
+ credential.mockResolvedValue({
+ version: 1,
+ sourceApp: BITKIT_SOURCE_APP,
+ pubky: SHARED,
+ secretKey: SECRET_B,
+ mnemonic: 'must-be-ignored',
+ });
+ derive.mockResolvedValue({
+ isOk: () => true,
+ value: { public_key: `pubky${SHARED}` },
+ } as never);
+
+ await expect(getSharedPubkyCredential({ pubky: SHARED, sourceApp: BITKIT_SOURCE_APP })).resolves.toEqual({
+ version: 1,
+ sourceApp: BITKIT_SOURCE_APP,
+ pubky: SHARED,
+ secretKey: SECRET_B,
+ });
+ expect(credential).toHaveBeenCalledWith(SHARED);
+
+ derive.mockResolvedValue({
+ isOk: () => true,
+ value: { public_key: `pubky${OTHER}` },
+ } as never);
+ await expect(
+ getSharedPubkyCredential({ pubky: SHARED, sourceApp: BITKIT_SOURCE_APP }),
+ ).resolves.toBeUndefined();
+});
+
+test('distinguishes unavailable sharing from an available empty source', async () => {
+ list.mockResolvedValue({ available: false, identities: [] });
+ await expect(discoverSharedPubkys([])).resolves.toEqual({ available: false, identities: [] });
+
+ list.mockResolvedValue({ available: true, identities: [] });
+ await expect(discoverSharedPubkys([])).resolves.toEqual({ available: true, identities: [] });
+
+ list.mockRejectedValue(new Error('missing entitlement'));
+ await expect(discoverSharedPubkys([])).resolves.toEqual({ available: false, identities: [] });
+});
+
+test('fails closed when native sharing is unavailable or rejects', async () => {
+ delete NativeModules.SharedPubky;
+ await expect(discoverSharedPubkys([])).resolves.toEqual({ available: false, identities: [] });
+ await expect(mirrorSharedPubky(OWNED, SECRET_A)).resolves.toBe(false);
+ await expect(removeSharedPubky(OWNED)).resolves.toBe(false);
+ await expect(clearOwnedSharedPubkys()).resolves.toBe(false);
+
+ NativeModules.SharedPubky = { mirror, remove, reconcile, clear, list, credential };
+ mirror.mockRejectedValue(new Error('store failed'));
+ remove.mockRejectedValue(new Error('store failed'));
+ reconcile.mockRejectedValue(new Error('store failed'));
+ clear.mockRejectedValue(new Error('store failed'));
+ await expect(mirrorSharedPubky(OWNED, SECRET_A)).resolves.toBe(false);
+ await expect(removeSharedPubky(OWNED)).resolves.toBe(false);
+ await expect(reconcileSharedPubkys([{ pubky: OWNED, secretKey: SECRET_A }])).resolves.toBe(false);
+ await expect(clearOwnedSharedPubkys()).resolves.toBe(false);
+});
+
+test('uses the expanded private access group only on iOS', () => {
+ expect(getPrivateKeychainAccessGroup()).toBeUndefined();
+ Object.defineProperty(Platform, 'OS', { configurable: true, value: 'ios' });
+ expect(getPrivateKeychainAccessGroup()).toBe('TEAM.app.pubkyring');
+});
+
+test('serializes identity lifecycle transactions across asynchronous gaps', async () => {
+ const events: string[] = [];
+ let releaseFirst: () => void = () => {};
+ const firstCanFinish = new Promise(resolve => {
+ releaseFirst = resolve;
+ });
+
+ const first = withPubkyIdentityLifecycle(async () => {
+ events.push('first-start');
+ await firstCanFinish;
+ events.push('first-end');
+ });
+ await Promise.resolve();
+ const second = withPubkyIdentityLifecycle(async () => {
+ events.push('second');
+ });
+ await Promise.resolve();
+
+ expect(events).toEqual(['first-start']);
+ releaseFirst();
+ await Promise.all([first, second]);
+ expect(events).toEqual(['first-start', 'first-end', 'second']);
+});
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 2eca5c55..d4525946 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -2,6 +2,101 @@ apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
+/**
+ * Ring's signature-protected provider is deliberately shared only with Bitkit.
+ *
+ * Production release builds therefore require all five properties below. The certificate
+ * fingerprint is the SHA-256 fingerprint of Bitkit's production app-signing certificate
+ * (64 hex characters, with or without colons), not an upload certificate unless they are
+ * the same certificate. Keep these values in protected Gradle/CI configuration.
+ */
+def bitkitSigningProperties = [
+ 'BITKIT_APP_SIGNING_STORE_FILE',
+ 'BITKIT_APP_SIGNING_STORE_PASSWORD',
+ 'BITKIT_APP_SIGNING_KEY_ALIAS',
+ 'BITKIT_APP_SIGNING_KEY_PASSWORD',
+ 'BITKIT_APP_SIGNING_CERT_SHA256',
+]
+def bitkitSigningValue = { String propertyName ->
+ def value = project.findProperty(propertyName)
+ value == null ? null : value.toString().trim()
+}
+def missingBitkitSigningProperties = bitkitSigningProperties.findAll {
+ !bitkitSigningValue(it)
+}
+def hasBitkitAppSigning = missingBitkitSigningProperties.isEmpty()
+
+def normalizeCertificateSha256 = { Object value, String propertyName ->
+ def raw = value == null ? "" : value.toString().trim()
+ def compact = raw.replace(":", "").replaceAll(/\s/, "").toUpperCase(Locale.ROOT)
+ if (!(compact ==~ /[0-9A-F]{64}/)) {
+ throw new GradleException(
+ "${propertyName} must be a SHA-256 certificate fingerprint " +
+ "(64 hexadecimal characters, with or without colons)."
+ )
+ }
+ compact
+}
+
+def formatCertificateSha256 = { String compact ->
+ compact.replaceAll(/([0-9A-F]{2})(?=[0-9A-F])/, '$1:')
+}
+
+def sha256OfCertificate = { java.security.cert.Certificate certificate ->
+ java.security.MessageDigest.getInstance("SHA-256")
+ .digest(certificate.encoded)
+ .collect { String.format("%02X", it & 0xff) }
+ .join()
+}
+
+def certificateSha256FromKeystore = {
+ File storeFile,
+ String storePassword,
+ String keyAlias,
+ String keyPassword ->
+
+ // JKS and PKCS12 cover Android's supported release-keystore formats. Trying both
+ // avoids trusting a filename extension to select the parser.
+ for (String storeType : ["JKS", "PKCS12"]) {
+ try {
+ def keyStore = java.security.KeyStore.getInstance(storeType)
+ storeFile.withInputStream {
+ keyStore.load(it, storePassword.toCharArray())
+ }
+ if (!keyStore.isKeyEntry(keyAlias)) {
+ continue
+ }
+
+ // Reading the private key validates the alias-specific key password as well.
+ if (keyStore.getKey(keyAlias, keyPassword.toCharArray()) == null) {
+ continue
+ }
+ def certificate = keyStore.getCertificate(keyAlias)
+ if (certificate != null) {
+ return sha256OfCertificate(certificate)
+ }
+ } catch (Exception ignored) {
+ // Try the other supported keystore format, then report a non-secret-bearing error.
+ }
+ }
+
+ throw new GradleException(
+ "Unable to read BITKIT_APP_SIGNING_KEY_ALIAS from BITKIT_APP_SIGNING_STORE_FILE " +
+ "using the supplied store/key passwords (supported formats: JKS and PKCS12)."
+ )
+}
+
+// This is the checked-in Android debug certificate. Debug builds continue to use it, but a
+// copied/renamed debug keystore must never satisfy the production release gate.
+def androidDebugCertificateSha256 =
+ "FAC61745DC0903786FB9EDE62A962B399F7348F0BB6F899B8332667591033B9C"
+
+// Debug builds normally load JavaScript from Metro. CI E2E runs without Metro, so its
+// explicit Gradle property asks the React plugin to package the bundle into the debug APK.
+def bundleDebugJs = project.findProperty("RING_BUNDLE_DEBUG_JS")
+ ?.toString()
+ ?.toBoolean() ?: false
+
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
@@ -21,7 +116,7 @@ react {
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
- // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
+ debuggableVariants = bundleDebugJs ? [] : ["debug", "debugOptimized"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
@@ -102,11 +197,11 @@ android {
}
defaultConfig {
- applicationId "to.pubky.ring"
+ applicationId "app.pubkyring"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 24
- versionName "1.17"
+ versionCode 1
+ versionName "2.0"
}
signingConfigs {
@@ -117,11 +212,11 @@ android {
keyPassword 'android'
}
release {
- if (project.hasProperty('PUBKYRING_UPLOAD_STORE_FILE')) {
- storeFile file(PUBKYRING_UPLOAD_STORE_FILE)
- storePassword PUBKYRING_UPLOAD_STORE_PASSWORD
- keyAlias PUBKYRING_UPLOAD_KEY_ALIAS
- keyPassword PUBKYRING_UPLOAD_KEY_PASSWORD
+ if (hasBitkitAppSigning) {
+ storeFile file(bitkitSigningValue('BITKIT_APP_SIGNING_STORE_FILE'))
+ storePassword bitkitSigningValue('BITKIT_APP_SIGNING_STORE_PASSWORD')
+ keyAlias bitkitSigningValue('BITKIT_APP_SIGNING_KEY_ALIAS')
+ keyPassword bitkitSigningValue('BITKIT_APP_SIGNING_KEY_PASSWORD')
}
}
}
@@ -130,15 +225,305 @@ android {
signingConfig signingConfigs.debug
}
release {
- // Caution! In production, you need to generate your own keystore file.
- // see https://reactnative.dev/docs/signed-apk-android.
- signingConfig project.hasProperty('PUBKYRING_UPLOAD_STORE_FILE') ? signingConfigs.release : signingConfigs.debug
+ // Never fall back to the debug certificate: an unsigned release is safer than an
+ // installable build that cannot use Bitkit's signature-protected provider.
+ if (hasBitkitAppSigning) {
+ signingConfig signingConfigs.release
+ }
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
}
+def verifyBitkitReleaseSigning = tasks.register("verifyBitkitReleaseSigning") {
+ group = "verification"
+ description = "Verifies that Ring release builds use Bitkit's pinned production certificate."
+
+ doLast {
+ if (!hasBitkitAppSigning) {
+ throw new GradleException(
+ "Ring release packaging is disabled without Bitkit's production signer. " +
+ "Missing Gradle properties: ${missingBitkitSigningProperties.join(', ')}"
+ )
+ }
+
+ def configuredStore = android.signingConfigs.release.storeFile
+ if (configuredStore == null || !configuredStore.isFile()) {
+ throw new GradleException(
+ "BITKIT_APP_SIGNING_STORE_FILE does not point to a readable keystore."
+ )
+ }
+
+ if (configuredStore.canonicalFile == file("debug.keystore").canonicalFile) {
+ throw new GradleException(
+ "Ring release packaging cannot use the Android debug keystore."
+ )
+ }
+
+ def expectedSha256 = normalizeCertificateSha256(
+ bitkitSigningValue('BITKIT_APP_SIGNING_CERT_SHA256'),
+ 'BITKIT_APP_SIGNING_CERT_SHA256'
+ )
+ if (expectedSha256 == androidDebugCertificateSha256) {
+ throw new GradleException(
+ "BITKIT_APP_SIGNING_CERT_SHA256 is the Android debug certificate, " +
+ "not Bitkit's production app-signing certificate."
+ )
+ }
+
+ def configuredSha256 = certificateSha256FromKeystore(
+ configuredStore,
+ bitkitSigningValue('BITKIT_APP_SIGNING_STORE_PASSWORD'),
+ bitkitSigningValue('BITKIT_APP_SIGNING_KEY_ALIAS'),
+ bitkitSigningValue('BITKIT_APP_SIGNING_KEY_PASSWORD')
+ )
+ if (configuredSha256 == androidDebugCertificateSha256) {
+ throw new GradleException(
+ "Ring release packaging cannot use the Android debug certificate, " +
+ "even from a copied or renamed keystore."
+ )
+ }
+ if (configuredSha256 != expectedSha256) {
+ throw new GradleException(
+ "Ring release signer mismatch. Keystore certificate SHA-256 is " +
+ "${formatCertificateSha256(configuredSha256)}, but " +
+ "BITKIT_APP_SIGNING_CERT_SHA256 is " +
+ "${formatCertificateSha256(expectedSha256)}."
+ )
+ }
+ }
+}
+
+def releaseApks = {
+ fileTree(new File(buildDir, "outputs/apk")) {
+ include "**/*.apk"
+ }.files.findAll { File apk ->
+ def normalizedPath = apk.absolutePath.replace(File.separatorChar, '/' as char)
+ normalizedPath.contains("/release/") ||
+ apk.name.toLowerCase(Locale.ROOT).contains("release")
+ }.sort { left, right -> left.absolutePath <=> right.absolutePath }
+}
+
+def releaseBundles = {
+ fileTree(new File(buildDir, "outputs/bundle")) {
+ include "**/*.aab"
+ }.files.findAll { File bundle ->
+ def normalizedPath = bundle.absolutePath.replace(File.separatorChar, '/' as char)
+ normalizedPath.contains("/release/") ||
+ bundle.name.toLowerCase(Locale.ROOT).contains("release")
+ }.sort { left, right -> left.absolutePath <=> right.absolutePath }
+}
+
+def verifyReleaseApk = { File apk, String expectedSha256 ->
+ if (!apk.isFile()) {
+ throw new GradleException("Ring release APK does not exist: ${apk}")
+ }
+
+ def executableName = System.getProperty("os.name")
+ .toLowerCase(Locale.ROOT)
+ .contains("windows") ? "apksigner.bat" : "apksigner"
+ def apksigner = new File(
+ new File(android.sdkDirectory, "build-tools/${android.buildToolsVersion}"),
+ executableName
+ )
+ if (!apksigner.isFile()) {
+ throw new GradleException(
+ "Cannot verify Ring's generated APK signer: apksigner was not found at ${apksigner}."
+ )
+ }
+
+ def process = new ProcessBuilder(
+ apksigner.absolutePath,
+ "verify",
+ "--print-certs",
+ apk.absolutePath
+ ).redirectErrorStream(true).start()
+ def output = process.inputStream.getText("UTF-8")
+ def exitCode = process.waitFor()
+ if (exitCode != 0) {
+ throw new GradleException(
+ "apksigner rejected Ring release APK ${apk.name}: ${output.trim()}"
+ )
+ }
+
+ def signerFingerprints = []
+ output.eachLine { String line ->
+ def matcher = line =~ /Signer #[0-9]+ certificate SHA-256 digest:\s*([0-9A-Fa-f]{64})/
+ if (matcher.find()) {
+ signerFingerprints.add(matcher.group(1).toUpperCase(Locale.ROOT))
+ }
+ }
+ if (signerFingerprints.size() != 1) {
+ throw new GradleException(
+ "Expected exactly one signer certificate in Ring release APK ${apk.name}; " +
+ "apksigner reported ${signerFingerprints.size()}."
+ )
+ }
+ if (signerFingerprints.first() != expectedSha256) {
+ throw new GradleException(
+ "Generated Ring release APK ${apk.name} signer SHA-256 is " +
+ "${formatCertificateSha256(signerFingerprints.first())}, but Bitkit's pinned " +
+ "production certificate is ${formatCertificateSha256(expectedSha256)}."
+ )
+ }
+}
+
+def verifyReleaseBundle = { File bundle, String expectedSha256 ->
+ if (!bundle.isFile()) {
+ throw new GradleException("Ring release bundle does not exist: ${bundle}")
+ }
+
+ def signerFingerprints = [] as Set
+ def unsignedEntries = []
+ def jarFile = new java.util.jar.JarFile(bundle, true)
+ try {
+ def entries = jarFile.entries()
+ while (entries.hasMoreElements()) {
+ def entry = entries.nextElement()
+ if (entry.directory || entry.name.toUpperCase(Locale.ROOT).startsWith("META-INF/")) {
+ continue
+ }
+
+ // JarFile performs signature/integrity verification while each entry is read.
+ jarFile.getInputStream(entry).withCloseable { input ->
+ byte[] buffer = new byte[8192]
+ while (input.read(buffer) != -1) {
+ // Drain the entry to force verification.
+ }
+ }
+
+ def codeSigners = entry.codeSigners
+ if (codeSigners == null || codeSigners.length == 0) {
+ unsignedEntries.add(entry.name)
+ continue
+ }
+ codeSigners.each { signer ->
+ def leafCertificate = signer.signerCertPath.certificates.first()
+ signerFingerprints.add(sha256OfCertificate(leafCertificate))
+ }
+ }
+ } catch (SecurityException exception) {
+ throw new GradleException(
+ "Ring release bundle ${bundle.name} failed JAR signature verification.",
+ exception
+ )
+ } finally {
+ jarFile.close()
+ }
+
+ if (!unsignedEntries.isEmpty()) {
+ throw new GradleException(
+ "Ring release bundle ${bundle.name} contains unsigned content " +
+ "(first entry: ${unsignedEntries.first()})."
+ )
+ }
+ if (signerFingerprints.size() != 1) {
+ throw new GradleException(
+ "Expected exactly one signer certificate in Ring release bundle ${bundle.name}; " +
+ "found ${signerFingerprints.size()}."
+ )
+ }
+ def actualSha256 = signerFingerprints.first()
+ if (actualSha256 != expectedSha256) {
+ throw new GradleException(
+ "Generated Ring release bundle ${bundle.name} signer SHA-256 is " +
+ "${formatCertificateSha256(actualSha256)}, but Bitkit's pinned production " +
+ "certificate is ${formatCertificateSha256(expectedSha256)}."
+ )
+ }
+}
+
+def verifyReleaseApks = { Collection apks ->
+ if (apks.isEmpty()) {
+ throw new GradleException(
+ "No Ring release APK was found to verify. Run assembleRelease first, or pass " +
+ "-PRING_RELEASE_APK=/absolute/path/to/ring-release.apk to " +
+ "verifyRingReleaseApkSigning."
+ )
+ }
+ def expectedSha256 = normalizeCertificateSha256(
+ bitkitSigningValue('BITKIT_APP_SIGNING_CERT_SHA256'),
+ 'BITKIT_APP_SIGNING_CERT_SHA256'
+ )
+ apks.each { File apk -> verifyReleaseApk(apk, expectedSha256) }
+}
+
+def verifyReleaseBundles = { Collection bundles ->
+ if (bundles.isEmpty()) {
+ throw new GradleException(
+ "No Ring release bundle was found to verify. Run bundleRelease first, or pass " +
+ "-PRING_RELEASE_BUNDLE=/absolute/path/to/ring-release.aab to " +
+ "verifyRingReleaseBundleSigning."
+ )
+ }
+ def expectedSha256 = normalizeCertificateSha256(
+ bitkitSigningValue('BITKIT_APP_SIGNING_CERT_SHA256'),
+ 'BITKIT_APP_SIGNING_CERT_SHA256'
+ )
+ bundles.each { File bundle -> verifyReleaseBundle(bundle, expectedSha256) }
+}
+
+def verifyRingReleaseApkSigning = tasks.register("verifyRingReleaseApkSigning") {
+ group = "verification"
+ description = "Verifies generated (or RING_RELEASE_APK) APK signer(s) against Bitkit's pin."
+ dependsOn(verifyBitkitReleaseSigning)
+
+ doLast {
+ def explicitApk = bitkitSigningValue('RING_RELEASE_APK')
+ verifyReleaseApks(explicitApk ? [file(explicitApk)] : releaseApks())
+ }
+}
+
+def verifyRingReleaseBundleSigning = tasks.register("verifyRingReleaseBundleSigning") {
+ group = "verification"
+ description = "Verifies generated (or RING_RELEASE_BUNDLE) AAB signer against Bitkit's pin."
+ dependsOn(verifyBitkitReleaseSigning)
+
+ doLast {
+ def explicitBundle = bitkitSigningValue('RING_RELEASE_BUNDLE')
+ verifyReleaseBundles(explicitBundle ? [file(explicitBundle)] : releaseBundles())
+ }
+}
+
+def isReleasePackagingTask = { String taskName ->
+ def lowerName = taskName.toLowerCase(Locale.ROOT)
+ if (!lowerName.contains("release")) {
+ return false
+ }
+
+ // Aggregate assemble/bundle tasks end in the build type. Package/sign tasks have
+ // additional AGP suffixes (for example packageReleaseUniversalApk).
+ (lowerName.startsWith("assemble") && lowerName.endsWith("release")) ||
+ (lowerName.startsWith("bundle") && lowerName.endsWith("release")) ||
+ ["package", "install", "sign", "makeapk", "extractapks", "zipapks"].any {
+ lowerName.startsWith(it)
+ }
+}
+
+tasks.configureEach { task ->
+ if (isReleasePackagingTask(task.name)) {
+ task.dependsOn(verifyBitkitReleaseSigning)
+ }
+
+ // APK signer verification is attached to every release assemble aggregate so it checks
+ // the actual v1/v2/v3/v4 signer(s) after AGP has produced all split/universal APKs.
+ if (task.name.toLowerCase(Locale.ROOT).startsWith("assemble") &&
+ task.name.toLowerCase(Locale.ROOT).endsWith("release")) {
+ task.doLast {
+ verifyReleaseApks(releaseApks())
+ }
+ }
+
+ // AABs use JAR signing, so force verification by reading every signed entry.
+ if (task.name.toLowerCase(Locale.ROOT).startsWith("bundle") &&
+ task.name.toLowerCase(Locale.ROOT).endsWith("release")) {
+ task.doLast {
+ verifyReleaseBundles(releaseBundles())
+ }
+ }
+}
+
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
index 16c8b173..ba124024 100644
--- a/android/app/src/debug/AndroidManifest.xml
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -1,3 +1,10 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 8110ec2c..213ca27c 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -8,6 +8,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/pubkyring/MainApplication.kt b/android/app/src/main/java/com/pubkyring/MainApplication.kt
index 66ebb889..f5d0ffd7 100644
--- a/android/app/src/main/java/com/pubkyring/MainApplication.kt
+++ b/android/app/src/main/java/com/pubkyring/MainApplication.kt
@@ -17,6 +17,7 @@ class MainApplication : Application(), ReactApplication {
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
add(AppInfoPackage())
+ add(SharedPubkyPackage())
},
)
}
diff --git a/android/app/src/main/java/com/pubkyring/SharedPubkyContract.kt b/android/app/src/main/java/com/pubkyring/SharedPubkyContract.kt
new file mode 100644
index 00000000..f215e805
--- /dev/null
+++ b/android/app/src/main/java/com/pubkyring/SharedPubkyContract.kt
@@ -0,0 +1,40 @@
+package to.pubkyring
+
+/** Wire contract shared by Pubky Ring and Bitkit. */
+object SharedPubkyContract {
+ const val VERSION = 1
+ const val RING_SOURCE_PACKAGE = "app.pubkyring"
+ const val BITKIT_SOURCE_PACKAGE = "to.bitkit"
+ const val IDENTITIES_PATH = "v1/identities"
+ const val CREDENTIAL_SEGMENT = "credential"
+
+ const val COLUMN_PROTOCOL_VERSION = "protocol_version"
+ const val COLUMN_SOURCE_PACKAGE = "source_package"
+ const val COLUMN_PUBKY = "pubky"
+ const val COLUMN_SECRET_KEY = "secret_key"
+
+ val PUBLIC_COLUMNS =
+ arrayOf(COLUMN_PROTOCOL_VERSION, COLUMN_SOURCE_PACKAGE, COLUMN_PUBKY)
+ val CREDENTIAL_COLUMNS =
+ arrayOf(COLUMN_PROTOCOL_VERSION, COLUMN_SOURCE_PACKAGE, COLUMN_PUBKY, COLUMN_SECRET_KEY)
+
+ private val PUBKY_PATTERN = Regex("^[ybndrfg8ejkmcpqxot1uwisza345h769]{52}$")
+ private val SECRET_KEY_PATTERN = Regex("^[0-9a-f]{64}$")
+
+ /** Canonical wire values are always the bare, lowercase 52-character z-base32 key. */
+ fun normalizePubky(value: String): String? {
+ val trimmed = value.trim()
+ if (isValidPubky(trimmed)) return trimmed
+ val raw =
+ if (trimmed.length == 57 && trimmed.startsWith("pubky")) {
+ trimmed.removePrefix("pubky")
+ } else {
+ return null
+ }
+ return raw.takeIf(::isValidPubky)
+ }
+
+ fun isValidPubky(pubky: String): Boolean = PUBKY_PATTERN.matches(pubky)
+
+ fun isValidSecretKey(secretKey: String): Boolean = SECRET_KEY_PATTERN.matches(secretKey)
+}
diff --git a/android/app/src/main/java/com/pubkyring/SharedPubkyModule.kt b/android/app/src/main/java/com/pubkyring/SharedPubkyModule.kt
new file mode 100644
index 00000000..23531f1c
--- /dev/null
+++ b/android/app/src/main/java/com/pubkyring/SharedPubkyModule.kt
@@ -0,0 +1,227 @@
+package to.pubkyring
+
+import android.content.pm.PackageManager
+import android.net.Uri
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.bridge.ReadableArray
+import org.json.JSONArray
+import org.json.JSONObject
+
+class SharedPubkyModule(private val reactContext: ReactApplicationContext) :
+ ReactContextBaseJavaModule(reactContext) {
+ override fun getName(): String = "SharedPubky"
+
+ @ReactMethod
+ fun mirror(pubky: String, secretKey: String, promise: Promise) {
+ try {
+ val normalized =
+ requireNotNull(SharedPubkyContract.normalizePubky(pubky)) { "Invalid pubky" }
+ SharedPubkyStore(reactContext).upsert(normalized, secretKey)
+ promise.resolve(null)
+ } catch (error: Exception) {
+ promise.reject("mirror_failed", error)
+ }
+ }
+
+ @ReactMethod
+ fun remove(pubky: String, promise: Promise) {
+ try {
+ val normalized =
+ requireNotNull(SharedPubkyContract.normalizePubky(pubky)) { "Invalid pubky" }
+ SharedPubkyStore(reactContext).remove(normalized)
+ promise.resolve(null)
+ } catch (error: Exception) {
+ promise.reject("remove_failed", error)
+ }
+ }
+
+ @ReactMethod
+ fun reconcile(identities: ReadableArray, promise: Promise) {
+ try {
+ val json = JSONArray()
+ for (index in 0 until identities.size()) {
+ val identity = identities.getMap(index) ?: throw IllegalArgumentException("Invalid identity")
+ val pubky =
+ requireNotNull(
+ SharedPubkyContract.normalizePubky(
+ requireNotNull(identity.getString("pubky")) { "Missing pubky" },
+ ),
+ ) {
+ "Invalid pubky"
+ }
+ val secretKey = requireNotNull(identity.getString("secretKey")) { "Missing secret key" }
+ json.put(
+ JSONObject()
+ .put("pubky", pubky)
+ .put("secretKey", secretKey),
+ )
+ }
+ SharedPubkyStore(reactContext).reconcile(json.toString())
+ promise.resolve(null)
+ } catch (error: Exception) {
+ promise.reject("reconcile_failed", error)
+ }
+ }
+
+ @ReactMethod
+ fun clear(promise: Promise) {
+ try {
+ SharedPubkyStore(reactContext).clear()
+ promise.resolve(null)
+ } catch (error: Exception) {
+ promise.reject("clear_failed", error)
+ }
+ }
+
+ @ReactMethod
+ fun list(promise: Promise) {
+ val identities = Arguments.createArray()
+ val seen = mutableSetOf()
+ val authorities =
+ if (BuildConfig.DEBUG) BITKIT_AUTHORITIES else listOf(PRODUCTION_BITKIT_AUTHORITY)
+ var available = false
+ authorities.forEach { authority ->
+ try {
+ if (!isTrustedBitkitProvider(authority)) return@forEach
+ val cursor =
+ reactContext.contentResolver.query(
+ Uri.parse("content://$authority/${SharedPubkyContract.IDENTITIES_PATH}"),
+ SharedPubkyContract.PUBLIC_COLUMNS,
+ null,
+ null,
+ null,
+ ) ?: return@forEach
+ cursor.use {
+ available = true
+ val versionIndex =
+ it.getColumnIndexOrThrow(SharedPubkyContract.COLUMN_PROTOCOL_VERSION)
+ val sourceIndex =
+ it.getColumnIndexOrThrow(SharedPubkyContract.COLUMN_SOURCE_PACKAGE)
+ val pubkyIndex = it.getColumnIndexOrThrow(SharedPubkyContract.COLUMN_PUBKY)
+ while (it.moveToNext()) {
+ val version = it.getInt(versionIndex)
+ val sourcePackage = it.getString(sourceIndex) ?: continue
+ val pubky = it.getString(pubkyIndex) ?: continue
+ if (
+ version != SharedPubkyContract.VERSION ||
+ sourcePackage != SharedPubkyContract.BITKIT_SOURCE_PACKAGE ||
+ !SharedPubkyContract.isValidPubky(pubky) ||
+ !seen.add(pubky)
+ ) {
+ continue
+ }
+ identities.pushMap(
+ Arguments.createMap().apply {
+ putInt("version", version)
+ putString("sourceApp", sourcePackage)
+ putString("pubky", pubky)
+ },
+ )
+ }
+ }
+ } catch (_: Exception) {
+ // A missing provider or denied permission must not hide results from another authority.
+ }
+ }
+ promise.resolve(
+ Arguments.createMap().apply {
+ putBoolean("available", available)
+ putArray("identities", identities)
+ },
+ )
+ }
+
+ @ReactMethod
+ fun credential(pubky: String, promise: Promise) {
+ val normalized = SharedPubkyContract.normalizePubky(pubky)
+ if (normalized == null) {
+ promise.reject("invalid_pubky", "Invalid pubky")
+ return
+ }
+ val authorities =
+ if (BuildConfig.DEBUG) BITKIT_AUTHORITIES else listOf(PRODUCTION_BITKIT_AUTHORITY)
+ authorities.forEach { authority ->
+ try {
+ if (!isTrustedBitkitProvider(authority)) return@forEach
+ val uri =
+ Uri.Builder()
+ .scheme("content")
+ .authority(authority)
+ .appendPath("v1")
+ .appendPath("identities")
+ .appendPath(normalized)
+ .appendPath(SharedPubkyContract.CREDENTIAL_SEGMENT)
+ .build()
+ reactContext.contentResolver
+ .query(uri, SharedPubkyContract.CREDENTIAL_COLUMNS, null, null, null)
+ ?.use { cursor ->
+ if (!cursor.moveToFirst() || !cursor.isLast) return@use
+ val version =
+ cursor.getInt(
+ cursor.getColumnIndexOrThrow(SharedPubkyContract.COLUMN_PROTOCOL_VERSION),
+ )
+ val sourcePackage =
+ cursor.getString(
+ cursor.getColumnIndexOrThrow(SharedPubkyContract.COLUMN_SOURCE_PACKAGE),
+ )
+ val returnedPubky =
+ cursor.getString(cursor.getColumnIndexOrThrow(SharedPubkyContract.COLUMN_PUBKY))
+ val secretKey =
+ cursor.getString(cursor.getColumnIndexOrThrow(SharedPubkyContract.COLUMN_SECRET_KEY))
+ if (
+ version == SharedPubkyContract.VERSION &&
+ sourcePackage == SharedPubkyContract.BITKIT_SOURCE_PACKAGE &&
+ returnedPubky == normalized &&
+ secretKey != null &&
+ SharedPubkyContract.isValidSecretKey(secretKey)
+ ) {
+ promise.resolve(
+ Arguments.createMap().apply {
+ putInt("version", version)
+ putString("sourceApp", sourcePackage)
+ putString("pubky", returnedPubky)
+ putString("secretKey", secretKey)
+ },
+ )
+ return
+ }
+ }
+ } catch (_: Exception) {
+ // Try another installed debug variant.
+ }
+ }
+ promise.reject("credential_unavailable", "Shared Pubky credential is unavailable")
+ }
+
+ private fun isTrustedBitkitProvider(authority: String): Boolean {
+ val provider =
+ reactContext.packageManager.resolveContentProvider(
+ authority,
+ PackageManager.MATCH_DIRECT_BOOT_AWARE or PackageManager.MATCH_DIRECT_BOOT_UNAWARE,
+ ) ?: return false
+ val expectedPackage = AUTHORITY_PACKAGES[authority] ?: return false
+ return provider.packageName == expectedPackage &&
+ reactContext.packageManager.checkSignatures(reactContext.packageName, provider.packageName) ==
+ PackageManager.SIGNATURE_MATCH
+ }
+
+ companion object {
+ private const val PRODUCTION_BITKIT_AUTHORITY = "to.bitkit.sharedpubky"
+ private val BITKIT_AUTHORITIES =
+ listOf(
+ PRODUCTION_BITKIT_AUTHORITY,
+ "to.bitkit.dev.sharedpubky",
+ "to.bitkit.tnet.sharedpubky",
+ )
+ private val AUTHORITY_PACKAGES =
+ mapOf(
+ "to.bitkit.sharedpubky" to "to.bitkit",
+ "to.bitkit.dev.sharedpubky" to "to.bitkit.dev",
+ "to.bitkit.tnet.sharedpubky" to "to.bitkit.tnet",
+ )
+ }
+}
diff --git a/android/app/src/main/java/com/pubkyring/SharedPubkyPackage.kt b/android/app/src/main/java/com/pubkyring/SharedPubkyPackage.kt
new file mode 100644
index 00000000..d0f316f8
--- /dev/null
+++ b/android/app/src/main/java/com/pubkyring/SharedPubkyPackage.kt
@@ -0,0 +1,16 @@
+package to.pubkyring
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+class SharedPubkyPackage : ReactPackage {
+ @Suppress("OVERRIDE_DEPRECATION")
+ override fun createNativeModules(reactContext: ReactApplicationContext): List =
+ listOf(SharedPubkyModule(reactContext))
+
+ override fun createViewManagers(
+ reactContext: ReactApplicationContext,
+ ): List> = emptyList()
+}
diff --git a/android/app/src/main/java/com/pubkyring/SharedPubkyProvider.kt b/android/app/src/main/java/com/pubkyring/SharedPubkyProvider.kt
new file mode 100644
index 00000000..ae3b1b62
--- /dev/null
+++ b/android/app/src/main/java/com/pubkyring/SharedPubkyProvider.kt
@@ -0,0 +1,132 @@
+package to.pubkyring
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.content.pm.PackageManager
+import android.database.Cursor
+import android.database.MatrixCursor
+import android.net.Uri
+import android.os.Binder
+
+/** Read-only, signature-protected identity provider for the production Bitkit package. */
+class SharedPubkyProvider : ContentProvider() {
+ override fun onCreate(): Boolean = true
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor {
+ enforceCaller()
+ require(selection == null && selectionArgs == null && sortOrder == null) { "Unsupported query" }
+
+ val providerContext = requireNotNull(context) { "Provider is unavailable" }
+ require(uri.authority == "${providerContext.packageName}.sharedpubky") { "Unsupported authority" }
+ val segments = uri.pathSegments
+ return when {
+ segments == listOf("v1", "identities") -> {
+ val columns = validatedProjection(projection, SharedPubkyContract.PUBLIC_COLUMNS)
+ MatrixCursor(columns).also { result ->
+ SharedPubkyStore(providerContext).list().forEach { identity ->
+ result.addRow(
+ columns.map { column ->
+ when (column) {
+ SharedPubkyContract.COLUMN_PROTOCOL_VERSION -> SharedPubkyContract.VERSION
+ SharedPubkyContract.COLUMN_SOURCE_PACKAGE ->
+ SharedPubkyContract.RING_SOURCE_PACKAGE
+ SharedPubkyContract.COLUMN_PUBKY -> identity.pubky
+ else -> error("Unsupported column")
+ }
+ },
+ )
+ }
+ }
+ }
+ segments.size == 4 &&
+ segments[0] == "v1" &&
+ segments[1] == "identities" &&
+ segments[3] == SharedPubkyContract.CREDENTIAL_SEGMENT -> {
+ val pubky = segments[2]
+ require(SharedPubkyContract.isValidPubky(pubky)) { "Invalid pubky" }
+ val columns = validatedProjection(projection, SharedPubkyContract.CREDENTIAL_COLUMNS)
+ MatrixCursor(columns).also { result ->
+ SharedPubkyStore(providerContext).get(pubky)?.let { identity ->
+ result.addRow(
+ columns.map { column ->
+ when (column) {
+ SharedPubkyContract.COLUMN_PROTOCOL_VERSION -> SharedPubkyContract.VERSION
+ SharedPubkyContract.COLUMN_SOURCE_PACKAGE ->
+ SharedPubkyContract.RING_SOURCE_PACKAGE
+ SharedPubkyContract.COLUMN_PUBKY -> identity.pubky
+ SharedPubkyContract.COLUMN_SECRET_KEY -> identity.secretKey
+ else -> error("Unsupported column")
+ }
+ },
+ )
+ }
+ }
+ }
+ else -> throw IllegalArgumentException("Unsupported URI")
+ }
+ }
+
+ private fun enforceCaller() {
+ val providerContext = context ?: throw SecurityException("Provider is unavailable")
+ val allowedPackages =
+ if (BuildConfig.DEBUG) DEBUG_BITKIT_PACKAGES else setOf(PRODUCTION_BITKIT_PACKAGE)
+ val caller = callingPackage
+ val callingUidPackages = providerContext.packageManager.getPackagesForUid(Binder.getCallingUid())
+ if (
+ caller !in allowedPackages ||
+ callingUidPackages == null ||
+ caller !in callingUidPackages ||
+ providerContext.packageManager.checkSignatures(providerContext.packageName, caller!!) !=
+ PackageManager.SIGNATURE_MATCH
+ ) {
+ throw SecurityException("Caller is not authorized")
+ }
+ }
+
+ private fun validatedProjection(
+ projection: Array?,
+ allowed: Array,
+ ): Array {
+ val requested = projection?.map { it.trim() }?.toTypedArray() ?: allowed
+ require(requested.isNotEmpty() && requested.distinct().size == requested.size) {
+ "Invalid projection"
+ }
+ require(requested.all { it in allowed }) { "Unsupported projection" }
+ return requested
+ }
+
+ override fun getType(uri: Uri): String {
+ enforceCaller()
+ return "vnd.android.cursor.dir/vnd.pubkyring.shared-pubky"
+ }
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? =
+ rejectWrite()
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Int = rejectWrite()
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int =
+ rejectWrite()
+
+ private fun rejectWrite(): T {
+ enforceCaller()
+ throw UnsupportedOperationException("Provider is read-only")
+ }
+
+ companion object {
+ private const val PRODUCTION_BITKIT_PACKAGE = "to.bitkit"
+ private val DEBUG_BITKIT_PACKAGES =
+ setOf(PRODUCTION_BITKIT_PACKAGE, "to.bitkit.dev", "to.bitkit.tnet")
+ }
+}
diff --git a/android/app/src/main/java/com/pubkyring/SharedPubkyStore.kt b/android/app/src/main/java/com/pubkyring/SharedPubkyStore.kt
new file mode 100644
index 00000000..dc235f2e
--- /dev/null
+++ b/android/app/src/main/java/com/pubkyring/SharedPubkyStore.kt
@@ -0,0 +1,154 @@
+package to.pubkyring
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** App-private AES-GCM encrypted mirror used by the native ContentProvider. */
+class SharedPubkyStore(context: Context) {
+ private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+
+ data class Identity(val pubky: String, val secretKey: String)
+
+ fun list(): List =
+ prefs.all.mapNotNull { (pubky, stored) ->
+ if (stored !is String) return@mapNotNull null
+ val plaintext = decrypt(stored) ?: return@mapNotNull null
+ try {
+ val json = JSONObject(plaintext)
+ val version = json.optInt("version")
+ val sourcePackage = json.optString("sourcePackage")
+ val storedPubky = json.optString("pubky")
+ val secretKey = json.optString("secretKey")
+ if (
+ version != SharedPubkyContract.VERSION ||
+ sourcePackage != SharedPubkyContract.RING_SOURCE_PACKAGE ||
+ pubky != storedPubky ||
+ !SharedPubkyContract.isValidPubky(pubky) ||
+ !SharedPubkyContract.isValidSecretKey(secretKey)
+ ) {
+ null
+ } else {
+ Identity(pubky, secretKey)
+ }
+ } catch (_: Exception) {
+ null
+ }
+ }.sortedBy { it.pubky }
+
+ fun get(pubky: String): Identity? = list().firstOrNull { it.pubky == pubky }
+
+ fun upsert(pubky: String, secretKey: String) {
+ require(SharedPubkyContract.isValidPubky(pubky)) { "pubky is invalid" }
+ require(SharedPubkyContract.isValidSecretKey(secretKey)) { "secretKey is invalid" }
+ val plaintext =
+ JSONObject()
+ .put("version", SharedPubkyContract.VERSION)
+ .put("sourcePackage", SharedPubkyContract.RING_SOURCE_PACKAGE)
+ .put("pubky", pubky)
+ .put("secretKey", secretKey)
+ .toString()
+ check(prefs.edit().putString(pubky, encrypt(plaintext)).commit()) {
+ "Unable to persist shared pubky mirror"
+ }
+ check(get(pubky)?.secretKey == secretKey) { "Shared pubky mirror read-back failed" }
+ }
+
+ fun remove(pubky: String) {
+ check(prefs.edit().remove(pubky).commit()) { "Unable to remove shared pubky mirror" }
+ check(get(pubky) == null) { "Shared pubky mirror still exists after removal" }
+ }
+
+ /**
+ * Replaces the mirror with the complete set of identities owned by Ring.
+ *
+ * The caller has already read and validated these values from Ring's canonical private
+ * keychain. Borrowed Bitkit identities are deliberately never passed to this method.
+ */
+ fun reconcile(identitiesJson: String) {
+ val identities = JSONArray(identitiesJson)
+ val desired = linkedMapOf()
+ for (index in 0 until identities.length()) {
+ val identity = identities.getJSONObject(index)
+ val pubky = identity.getString("pubky")
+ val secretKey = identity.getString("secretKey")
+ require(SharedPubkyContract.isValidPubky(pubky)) { "pubky is invalid" }
+ require(SharedPubkyContract.isValidSecretKey(secretKey)) { "secretKey is invalid" }
+ check(desired.put(pubky, secretKey) == null) { "Duplicate pubky" }
+ }
+
+ desired.forEach { (pubky, secretKey) -> upsert(pubky, secretKey) }
+ prefs.all.keys.filterNot(desired::containsKey).forEach(::remove)
+ check(list().associate { it.pubky to it.secretKey } == desired) {
+ "Shared pubky reconciliation read-back failed"
+ }
+ }
+
+ fun clear() {
+ check(prefs.edit().clear().commit()) { "Unable to clear shared pubky mirrors" }
+ check(prefs.all.isEmpty()) { "Shared pubky mirrors still exist after clear" }
+
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
+ if (keyStore.containsAlias(KEY_ALIAS)) {
+ keyStore.deleteEntry(KEY_ALIAS)
+ check(!keyStore.containsAlias(KEY_ALIAS)) { "Shared pubky encryption key still exists after clear" }
+ }
+ }
+
+ private fun encrypt(plaintext: String): String {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, secretKey())
+ val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8))
+ return Base64.encodeToString(cipher.iv + ciphertext, Base64.NO_WRAP)
+ }
+
+ private fun decrypt(stored: String): String? {
+ return try {
+ val combined = Base64.decode(stored, Base64.NO_WRAP)
+ if (combined.size <= IV_LENGTH) return null
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(
+ Cipher.DECRYPT_MODE,
+ secretKey(),
+ GCMParameterSpec(GCM_TAG_BITS, combined.copyOfRange(0, IV_LENGTH)),
+ )
+ String(cipher.doFinal(combined.copyOfRange(IV_LENGTH, combined.size)), Charsets.UTF_8)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun secretKey(): SecretKey {
+ val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
+ (keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
+ val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
+ generator.init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .setKeySize(256)
+ .build(),
+ )
+ return generator.generateKey()
+ }
+
+ companion object {
+ private const val PREFS_NAME = "shared_pubky_store"
+ private const val ANDROID_KEYSTORE = "AndroidKeyStore"
+ private const val KEY_ALIAS = "shared_pubky"
+ private const val TRANSFORMATION = "AES/GCM/NoPadding"
+ private const val IV_LENGTH = 12
+ private const val GCM_TAG_BITS = 128
+ }
+}
diff --git a/ios/pubkyring.xcodeproj/project.pbxproj b/ios/pubkyring.xcodeproj/project.pbxproj
index dadcfa95..afeb7d81 100644
--- a/ios/pubkyring.xcodeproj/project.pbxproj
+++ b/ios/pubkyring.xcodeproj/project.pbxproj
@@ -10,6 +10,7 @@
1319212F6007D5772A620B61 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
4B1A7B8D2C3D4E5F00112233 /* AppInfo.m in Sources */ = {isa = PBXBuildFile; fileRef = 4B1A7B8C2C3D4E5F00112233 /* AppInfo.m */; };
+ 4B1A7B8F2C3D4E5F00112233 /* SharedPubky.m in Sources */ = {isa = PBXBuildFile; fileRef = 4B1A7B8E2C3D4E5F00112233 /* SharedPubky.m */; };
70F9B56BE652426C904D16F4 /* InterTight-VariableFont_wght.ttf in Resources */ = {isa = PBXBuildFile; fileRef = C21B8554B47148DBBCC60B15 /* InterTight-VariableFont_wght.ttf */; };
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; };
81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; };
@@ -22,6 +23,8 @@
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = pubkyring/Info.plist; sourceTree = ""; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = pubkyring/PrivacyInfo.xcprivacy; sourceTree = ""; };
4B1A7B8C2C3D4E5F00112233 /* AppInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppInfo.m; path = pubkyring/AppInfo.m; sourceTree = ""; };
+ 4B1A7B8E2C3D4E5F00112233 /* SharedPubky.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = SharedPubky.m; path = pubkyring/SharedPubky.m; sourceTree = ""; };
+ 4B1A7B902C3D4E5F00112233 /* pubkyring.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = pubkyring.entitlements; path = pubkyring/pubkyring.entitlements; sourceTree = ""; };
5F66FC2D5C5C4019367FA619 /* libPods-pubkyring.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-pubkyring.a"; sourceTree = BUILT_PRODUCTS_DIR; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = pubkyring/AppDelegate.swift; sourceTree = ""; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = pubkyring/LaunchScreen.storyboard; sourceTree = ""; };
@@ -48,6 +51,8 @@
children = (
13B07FB51A68108700A75B9A /* Images.xcassets */,
4B1A7B8C2C3D4E5F00112233 /* AppInfo.m */,
+ 4B1A7B8E2C3D4E5F00112233 /* SharedPubky.m */,
+ 4B1A7B902C3D4E5F00112233 /* pubkyring.entitlements */,
761780EC2CA45674006654EE /* AppDelegate.swift */,
13B07FB61A68108700A75B9A /* Info.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
@@ -262,6 +267,7 @@
buildActionMask = 2147483647;
files = (
4B1A7B8D2C3D4E5F00112233 /* AppInfo.m in Sources */,
+ 4B1A7B8F2C3D4E5F00112233 /* SharedPubky.m in Sources */,
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -275,6 +281,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = pubkyring/pubkyring.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KYH47R284B;
ENABLE_BITCODE = NO;
@@ -294,6 +301,8 @@
);
PRODUCT_BUNDLE_IDENTIFIER = app.pubkyring;
PRODUCT_NAME = pubkyring;
+ PUBKY_PRIVATE_KEYCHAIN_GROUP = "$(AppIdentifierPrefix)app.pubkyring";
+ PUBKY_SHARED_KEYCHAIN_GROUP = "$(AppIdentifierPrefix)pubky.shared";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -306,6 +315,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
+ CODE_SIGN_ENTITLEMENTS = pubkyring/pubkyring.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = KYH47R284B;
INFOPLIST_FILE = pubkyring/Info.plist;
@@ -324,6 +334,8 @@
);
PRODUCT_BUNDLE_IDENTIFIER = app.pubkyring;
PRODUCT_NAME = pubkyring;
+ PUBKY_PRIVATE_KEYCHAIN_GROUP = "$(AppIdentifierPrefix)app.pubkyring";
+ PUBKY_SHARED_KEYCHAIN_GROUP = "$(AppIdentifierPrefix)pubky.shared";
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
diff --git a/ios/pubkyring/Info.plist b/ios/pubkyring/Info.plist
index 80e620c4..aaec073d 100644
--- a/ios/pubkyring/Info.plist
+++ b/ios/pubkyring/Info.plist
@@ -40,6 +40,10 @@
$(CURRENT_PROJECT_VERSION)
LSRequiresIPhoneOS
+ LSApplicationQueriesSchemes
+
+ bitkit
+
LSSupportsOpeningDocumentsInPlace
NSAppTransportSecurity
@@ -51,6 +55,10 @@
Pubky Ring needs access to your camera to scan QR codes for signing in. If you choose not to use the camera, you can paste the QR code string instead.
NSPhotoLibraryUsageDescription
$(PRODUCT_NAME) requires access to the photo library to save and load files.
+ PubkyPrivateKeychainAccessGroup
+ $(PUBKY_PRIVATE_KEYCHAIN_GROUP)
+ PubkySharedKeychainAccessGroup
+ $(PUBKY_SHARED_KEYCHAIN_GROUP)
RCTNewArchEnabled
UIAppFonts
diff --git a/ios/pubkyring/SharedPubky.m b/ios/pubkyring/SharedPubky.m
new file mode 100644
index 00000000..ae9544d9
--- /dev/null
+++ b/ios/pubkyring/SharedPubky.m
@@ -0,0 +1,487 @@
+#import
+#import
+#import
+
+static NSInteger const SharedPubkyProtocolVersion = 1;
+static NSString *const SharedPubkyService = @"pubky.identity-sharing.v1";
+static NSString *const RingSourceApp = @"app.pubkyring";
+static NSString *const BitkitSourceApp = @"to.bitkit";
+
+@interface SharedPubky : NSObject
+@end
+
+@implementation SharedPubky
+
+RCT_EXPORT_MODULE();
+
++ (BOOL)requiresMainQueueSetup
+{
+ return NO;
+}
+
+- (NSDictionary *)constantsToExport
+{
+ return @{
+ @"privateAccessGroup" : [self privateAccessGroup] ?: @"",
+ @"sharedAccessGroup" : [self sharedAccessGroup] ?: @"",
+ @"protocolVersion" : @(SharedPubkyProtocolVersion),
+ @"sourceApp" : RingSourceApp,
+ };
+}
+
+RCT_REMAP_METHOD(mirror,
+ mirrorPubky:(NSString *)pubky
+ secretKey:(NSString *)secretKey
+ mirrorResolver:(RCTPromiseResolveBlock)resolve
+ mirrorRejecter:(RCTPromiseRejectBlock)reject)
+{
+ NSString *normalized = [self normalizedPubky:pubky];
+ if (normalized.length == 0 || ![self isValidSecretKey:secretKey]) {
+ reject(@"invalid_identity", @"Pubky or secret key is invalid", nil);
+ return;
+ }
+
+ NSDictionary *payload = @{
+ @"version" : @(SharedPubkyProtocolVersion),
+ @"sourceApp" : RingSourceApp,
+ @"pubky" : normalized,
+ @"secretKey" : secretKey,
+ };
+ NSError *error;
+ if (![self upsertPayload:payload account:[self accountForSource:RingSourceApp pubky:normalized] error:&error]) {
+ [self reject:reject error:error fallbackCode:@"mirror_failed"];
+ return;
+ }
+ resolve(nil);
+}
+
+RCT_REMAP_METHOD(remove,
+ removePubky:(NSString *)pubky
+ removeResolver:(RCTPromiseResolveBlock)resolve
+ removeRejecter:(RCTPromiseRejectBlock)reject)
+{
+ NSError *error;
+ if (![self deleteAccount:[self accountForSource:RingSourceApp pubky:[self normalizedPubky:pubky]]
+ error:&error]) {
+ [self reject:reject error:error fallbackCode:@"remove_failed"];
+ return;
+ }
+ resolve(nil);
+}
+
+RCT_REMAP_METHOD(reconcile,
+ reconcileIdentities:(NSArray *)identities
+ reconcileResolver:(RCTPromiseResolveBlock)resolve
+ reconcileRejecter:(RCTPromiseRejectBlock)reject)
+{
+ NSMutableSet *desiredAccounts = [NSMutableSet set];
+ for (NSDictionary *identity in identities) {
+ NSString *pubky = [self normalizedPubky:identity[@"pubky"]];
+ NSString *secretKey = identity[@"secretKey"];
+ if (pubky.length == 0 || ![self isValidSecretKey:secretKey]) {
+ reject(@"invalid_identity", @"Invalid identity supplied for reconciliation", nil);
+ return;
+ }
+ NSString *account = [self accountForSource:RingSourceApp pubky:pubky];
+ if ([desiredAccounts containsObject:account]) {
+ reject(@"invalid_identity", @"Duplicate identity supplied for reconciliation", nil);
+ return;
+ }
+ [desiredAccounts addObject:account];
+ NSDictionary *payload = @{
+ @"version" : @(SharedPubkyProtocolVersion),
+ @"sourceApp" : RingSourceApp,
+ @"pubky" : pubky,
+ @"secretKey" : secretKey,
+ };
+ NSError *error;
+ if (![self upsertPayload:payload account:account error:&error]) {
+ [self reject:reject error:error fallbackCode:@"reconcile_failed"];
+ return;
+ }
+ }
+
+ NSError *listError;
+ NSArray *owned = [self attributesForSource:RingSourceApp error:&listError];
+ if (owned == nil) {
+ [self reject:reject error:listError fallbackCode:@"reconcile_failed"];
+ return;
+ }
+ for (NSDictionary *attributes in owned) {
+ NSString *account = attributes[(__bridge id)kSecAttrAccount];
+ if (![desiredAccounts containsObject:account]) {
+ NSError *deleteError;
+ if (![self deleteAccount:account error:&deleteError]) {
+ [self reject:reject error:deleteError fallbackCode:@"reconcile_failed"];
+ return;
+ }
+ }
+ }
+ resolve(nil);
+}
+
+RCT_REMAP_METHOD(clear,
+ clearResolver:(RCTPromiseResolveBlock)resolve
+ clearRejecter:(RCTPromiseRejectBlock)reject)
+{
+ NSError *listError;
+ NSArray *owned = [self attributesForSource:RingSourceApp error:&listError];
+ if (owned == nil) {
+ [self reject:reject error:listError fallbackCode:@"clear_failed"];
+ return;
+ }
+ for (NSDictionary *attributes in owned) {
+ NSError *deleteError;
+ if (![self deleteAccount:attributes[(__bridge id)kSecAttrAccount] error:&deleteError]) {
+ [self reject:reject error:deleteError fallbackCode:@"clear_failed"];
+ return;
+ }
+ }
+ resolve(nil);
+}
+
+RCT_REMAP_METHOD(list,
+ listResolver:(RCTPromiseResolveBlock)resolve
+ listRejecter:(RCTPromiseRejectBlock)reject)
+{
+ if (![self isBitkitInstalled]) {
+ resolve(@{ @"available" : @NO, @"identities" : @[] });
+ return;
+ }
+
+ NSError *error;
+ NSArray *attributes = [self attributesForSource:BitkitSourceApp error:&error];
+ if (attributes == nil) {
+ [self reject:reject error:error fallbackCode:@"list_failed"];
+ return;
+ }
+
+ NSMutableArray *identities = [NSMutableArray array];
+ NSMutableSet *seen = [NSMutableSet set];
+ for (NSDictionary *item in attributes) {
+ NSString *account = item[(__bridge id)kSecAttrAccount];
+ NSString *prefix = [NSString stringWithFormat:@"%@:", BitkitSourceApp];
+ if (![account hasPrefix:prefix]) {
+ continue;
+ }
+ NSString *pubky = [self normalizedPubky:[account substringFromIndex:prefix.length]];
+ if (pubky.length == 0 || [seen containsObject:pubky]) {
+ continue;
+ }
+ [seen addObject:pubky];
+ [identities addObject:@{
+ @"version" : @(SharedPubkyProtocolVersion),
+ @"sourceApp" : BitkitSourceApp,
+ @"pubky" : pubky,
+ }];
+ }
+ [identities sortUsingComparator:^NSComparisonResult(NSDictionary *left, NSDictionary *right) {
+ return [left[@"pubky"] compare:right[@"pubky"]];
+ }];
+ resolve(@{ @"available" : @YES, @"identities" : identities });
+}
+
+RCT_REMAP_METHOD(credential,
+ credentialPubky:(NSString *)pubky
+ credentialResolver:(RCTPromiseResolveBlock)resolve
+ credentialRejecter:(RCTPromiseRejectBlock)reject)
+{
+ NSString *normalized = [self normalizedPubky:pubky];
+ if (normalized.length == 0 || ![self isBitkitInstalled]) {
+ reject(@"credential_unavailable", @"Shared Pubky credential is unavailable", nil);
+ return;
+ }
+
+ NSError *error;
+ NSDictionary *payload =
+ [self payloadForAccount:[self accountForSource:BitkitSourceApp pubky:normalized] error:&error];
+ if (payload == nil) {
+ [self reject:reject error:error fallbackCode:@"credential_unavailable"];
+ return;
+ }
+ if (![self isValidPayload:payload expectedSource:BitkitSourceApp expectedPubky:normalized]) {
+ reject(@"invalid_credential", @"Shared Pubky credential is invalid", nil);
+ return;
+ }
+ resolve(payload);
+}
+
+RCT_REMAP_METHOD(privateServices,
+ privateServicesResolver:(RCTPromiseResolveBlock)resolve
+ privateServicesRejecter:(RCTPromiseRejectBlock)reject)
+{
+ NSString *accessGroup = [self privateAccessGroup];
+ if (accessGroup.length == 0) {
+ reject(@"private_keychain_unavailable", @"Private keychain access group is unavailable", nil);
+ return;
+ }
+ NSDictionary *query = @{
+ (__bridge id)kSecClass : (__bridge id)kSecClassGenericPassword,
+ (__bridge id)kSecAttrAccessGroup : accessGroup,
+ (__bridge id)kSecReturnAttributes : @YES,
+ (__bridge id)kSecMatchLimit : (__bridge id)kSecMatchLimitAll,
+ };
+ CFTypeRef result = NULL;
+ OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
+ if (status == errSecItemNotFound) {
+ resolve(@[]);
+ return;
+ }
+ if (status != errSecSuccess) {
+ NSError *error;
+ [self setError:&error status:status];
+ reject(@"private_keychain_unavailable", error.localizedDescription, error);
+ return;
+ }
+ NSArray *attributes = CFBridgingRelease(result);
+ NSMutableOrderedSet *services = [NSMutableOrderedSet orderedSet];
+ for (NSDictionary *item in attributes) {
+ NSString *service = item[(__bridge id)kSecAttrService];
+ if ([service isKindOfClass:NSString.class] && service.length > 0) {
+ [services addObject:service];
+ }
+ }
+ resolve(services.array);
+}
+
+- (NSString *)privateAccessGroup
+{
+ NSString *value = NSBundle.mainBundle.infoDictionary[@"PubkyPrivateKeychainAccessGroup"];
+ return [value isKindOfClass:NSString.class] && ![value containsString:@"$("] ? value : nil;
+}
+
+- (NSString *)sharedAccessGroup
+{
+ NSString *value = NSBundle.mainBundle.infoDictionary[@"PubkySharedKeychainAccessGroup"];
+ return [value isKindOfClass:NSString.class] && ![value containsString:@"$("] ? value : nil;
+}
+
+- (NSString *)normalizedPubky:(id)value
+{
+ if (![value isKindOfClass:NSString.class]) {
+ return @"";
+ }
+ NSString *pubky = [(NSString *)value stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet];
+ if (pubky.length == 57 && [pubky hasPrefix:@"pubky"]) {
+ pubky = [pubky substringFromIndex:5];
+ }
+ if (pubky.length != 52) {
+ return @"";
+ }
+ static NSCharacterSet *invalidCharacters;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ invalidCharacters =
+ [[NSCharacterSet characterSetWithCharactersInString:@"ybndrfg8ejkmcpqxot1uwisza345h769"] invertedSet];
+ });
+ if ([pubky rangeOfCharacterFromSet:invalidCharacters].location != NSNotFound) {
+ return @"";
+ }
+ return pubky;
+}
+
+- (NSString *)accountForSource:(NSString *)source pubky:(NSString *)pubky
+{
+ return [NSString stringWithFormat:@"%@:%@", source, pubky];
+}
+
+- (BOOL)isValidSecretKey:(id)value
+{
+ if (![value isKindOfClass:NSString.class] || [(NSString *)value length] != 64) {
+ return NO;
+ }
+ static NSCharacterSet *invalidCharacters;
+ static dispatch_once_t onceToken;
+ dispatch_once(&onceToken, ^{
+ invalidCharacters =
+ [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdef"] invertedSet];
+ });
+ return [(NSString *)value rangeOfCharacterFromSet:invalidCharacters].location == NSNotFound;
+}
+
+- (NSMutableDictionary *)baseQueryForAccount:(NSString *)account
+{
+ NSString *accessGroup = [self sharedAccessGroup];
+ if (accessGroup.length == 0) {
+ return nil;
+ }
+ NSMutableDictionary *query = [@{
+ (__bridge id)kSecClass : (__bridge id)kSecClassGenericPassword,
+ (__bridge id)kSecAttrService : SharedPubkyService,
+ (__bridge id)kSecAttrAccessGroup : accessGroup,
+ (__bridge id)kSecAttrSynchronizable : @NO,
+ } mutableCopy];
+ if (account != nil) {
+ query[(__bridge id)kSecAttrAccount] = account;
+ }
+ return query;
+}
+
+- (BOOL)upsertPayload:(NSDictionary *)payload account:(NSString *)account error:(NSError **)error
+{
+ NSData *data = [NSJSONSerialization dataWithJSONObject:payload options:0 error:error];
+ if (data == nil) {
+ return NO;
+ }
+ NSMutableDictionary *query = [self baseQueryForAccount:account];
+ if (query == nil) {
+ [self setMissingEntitlementError:error];
+ return NO;
+ }
+ NSDictionary *updates = @{
+ (__bridge id)kSecValueData : data,
+ (__bridge id)kSecAttrAccessible : (__bridge id)kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
+ };
+ OSStatus status = SecItemUpdate((__bridge CFDictionaryRef)query, (__bridge CFDictionaryRef)updates);
+ if (status == errSecItemNotFound) {
+ [query addEntriesFromDictionary:updates];
+ status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
+ }
+ if (status != errSecSuccess) {
+ [self setError:error status:status];
+ return NO;
+ }
+
+ NSError *readError;
+ NSDictionary *stored = [self payloadForAccount:account error:&readError];
+ if (stored == nil || ![stored isEqualToDictionary:payload]) {
+ if (error != NULL) {
+ *error = readError ?: [NSError errorWithDomain:NSOSStatusErrorDomain
+ code:errSecDecode
+ userInfo:@{NSLocalizedDescriptionKey : @"Shared keychain read-back failed"}];
+ }
+ return NO;
+ }
+ return YES;
+}
+
+- (NSDictionary *)payloadForAccount:(NSString *)account error:(NSError **)error
+{
+ NSMutableDictionary *query = [self baseQueryForAccount:account];
+ if (query == nil) {
+ [self setMissingEntitlementError:error];
+ return nil;
+ }
+ query[(__bridge id)kSecReturnData] = @YES;
+ query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
+ CFTypeRef result = NULL;
+ OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
+ if (status != errSecSuccess) {
+ [self setError:error status:status];
+ return nil;
+ }
+ NSData *data = CFBridgingRelease(result);
+ id json = [NSJSONSerialization JSONObjectWithData:data options:0 error:error];
+ return [json isKindOfClass:NSDictionary.class] ? json : nil;
+}
+
+- (NSArray *)attributesForSource:(NSString *)source error:(NSError **)error
+{
+ NSMutableDictionary *query = [self baseQueryForAccount:nil];
+ if (query == nil) {
+ [self setMissingEntitlementError:error];
+ return nil;
+ }
+ query[(__bridge id)kSecReturnAttributes] = @YES;
+ query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitAll;
+ CFTypeRef result = NULL;
+ OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
+ if (status == errSecItemNotFound) {
+ return @[];
+ }
+ if (status != errSecSuccess) {
+ [self setError:error status:status];
+ return nil;
+ }
+ NSArray *all = CFBridgingRelease(result);
+ NSString *prefix = [NSString stringWithFormat:@"%@:", source];
+ return [all filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *item, id _) {
+ NSString *account = item[(__bridge id)kSecAttrAccount];
+ return [account isKindOfClass:NSString.class] && [account hasPrefix:prefix];
+ }]];
+}
+
+- (BOOL)deleteAccount:(NSString *)account error:(NSError **)error
+{
+ if (account.length == 0) {
+ return YES;
+ }
+ NSMutableDictionary *query = [self baseQueryForAccount:account];
+ if (query == nil) {
+ [self setMissingEntitlementError:error];
+ return NO;
+ }
+ OSStatus status = SecItemDelete((__bridge CFDictionaryRef)query);
+ if (status != errSecSuccess && status != errSecItemNotFound) {
+ [self setError:error status:status];
+ return NO;
+ }
+ CFTypeRef result = NULL;
+ status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
+ if (result != NULL) {
+ CFRelease(result);
+ }
+ if (status != errSecItemNotFound) {
+ [self setError:error status:status == errSecSuccess ? errSecDuplicateItem : status];
+ return NO;
+ }
+ return YES;
+}
+
+- (BOOL)isValidPayload:(NSDictionary *)payload
+ expectedSource:(NSString *)source
+ expectedPubky:(NSString *)pubky
+{
+ id version = payload[@"version"];
+ BOOL isIntegerVersion =
+ [version isKindOfClass:NSNumber.class] &&
+ CFGetTypeID((__bridge CFTypeRef)version) == CFNumberGetTypeID() &&
+ !CFNumberIsFloatType((__bridge CFNumberRef)version);
+ return isIntegerVersion &&
+ [version integerValue] == SharedPubkyProtocolVersion &&
+ [payload[@"sourceApp"] isEqualToString:source] &&
+ [payload[@"pubky"] isKindOfClass:NSString.class] &&
+ [payload[@"pubky"] isEqualToString:pubky] &&
+ [self isValidSecretKey:payload[@"secretKey"]];
+}
+
+- (BOOL)isBitkitInstalled
+{
+ // URL-scheme presence is only a UX availability hint; it is not an app identity proof.
+ // The shared Keychain entitlement and strict source-owned payload validation are the trust boundary.
+ __block BOOL installed = NO;
+ void (^check)(void) = ^{
+ installed = [UIApplication.sharedApplication canOpenURL:[NSURL URLWithString:@"bitkit://"]];
+ };
+ if (NSThread.isMainThread) {
+ check();
+ } else {
+ dispatch_sync(dispatch_get_main_queue(), check);
+ }
+ return installed;
+}
+
+- (void)setMissingEntitlementError:(NSError **)error
+{
+ [self setError:error status:errSecMissingEntitlement];
+}
+
+- (void)setError:(NSError **)error status:(OSStatus)status
+{
+ if (error != NULL) {
+ NSString *message = CFBridgingRelease(SecCopyErrorMessageString(status, NULL)) ?: @"Keychain operation failed";
+ *error = [NSError errorWithDomain:NSOSStatusErrorDomain
+ code:status
+ userInfo:@{NSLocalizedDescriptionKey : message}];
+ }
+}
+
+- (void)reject:(RCTPromiseRejectBlock)reject
+ error:(NSError *)error
+ fallbackCode:(NSString *)fallbackCode
+{
+ NSString *code = error.code == errSecMissingEntitlement ? @"sharing_unavailable" : fallbackCode;
+ reject(code, error.localizedDescription ?: @"Shared Pubky operation failed", error);
+}
+
+@end
diff --git a/ios/pubkyring/pubkyring.entitlements b/ios/pubkyring/pubkyring.entitlements
new file mode 100644
index 00000000..e27dfab7
--- /dev/null
+++ b/ios/pubkyring/pubkyring.entitlements
@@ -0,0 +1,11 @@
+
+
+
+
+ keychain-access-groups
+
+ $(PUBKY_PRIVATE_KEYCHAIN_GROUP)
+ $(PUBKY_SHARED_KEYCHAIN_GROUP)
+
+
+
diff --git a/src/components/ProfileAvatar.tsx b/src/components/ProfileAvatar.tsx
index 8def4a68..10b86f42 100644
--- a/src/components/ProfileAvatar.tsx
+++ b/src/components/ProfileAvatar.tsx
@@ -15,6 +15,7 @@ interface ProfileAvatarProps {
pubky: string;
name?: string;
size?: number;
+ image?: string;
}
const resolveFallbackSeed = (pubky: string): string => {
@@ -31,9 +32,10 @@ const resolveFallbackInitial = (name: string | undefined, seed: string): string
return seed.trim().charAt(0).toUpperCase();
};
-const ProfileAvatar = ({ pubky, name, size = 32 }: ProfileAvatarProps): ReactElement => {
+const ProfileAvatar = ({ pubky, name, size = 32, image }: ProfileAvatarProps): ReactElement => {
const fallbackSeed = useMemo(() => resolveFallbackSeed(pubky), [pubky]);
- const imageUri = useSelector((state: RootState) => getPubkyImage(state, fallbackSeed));
+ const storedImageUri = useSelector((state: RootState) => getPubkyImage(state, fallbackSeed));
+ const imageUri = image || storedImageUri;
const fallbackInitial = useMemo(() => resolveFallbackInitial(name, fallbackSeed), [name, fallbackSeed]);
// Memoize style object to prevent unnecessary re-renders
diff --git a/src/components/PubkyBox.tsx b/src/components/PubkyBox.tsx
index 2db477ac..322c3f30 100644
--- a/src/components/PubkyBox.tsx
+++ b/src/components/PubkyBox.tsx
@@ -18,9 +18,10 @@ interface PubkyInfoProps {
publicKey: string;
sessionsCount: number;
isBackedUp: boolean;
+ isBorrowed: boolean;
}
-const PubkyInfo = memo(({ pubkyName, publicKey, sessionsCount, isBackedUp }: PubkyInfoProps) => {
+const PubkyInfo = memo(({ pubkyName, publicKey, sessionsCount, isBackedUp, isBorrowed }: PubkyInfoProps) => {
const { t } = useTranslation();
const handleBackupPress = useCallback(() => {
@@ -42,7 +43,7 @@ const PubkyInfo = memo(({ pubkyName, publicKey, sessionsCount, isBackedUp }: Pub
- {!isBackedUp && (
+ {!isBackedUp && !isBorrowed ? (
{t('pubkyProfile.backupReminder')}
- )}
+ ) : null}
{sessionsCount > 0 && (
@@ -58,6 +59,8 @@ const PubkyInfo = memo(({ pubkyName, publicKey, sessionsCount, isBackedUp }: Pub
)}
+
+ {isBorrowed && {t('reuseSharedPubky.source')}}
);
});
@@ -128,6 +131,7 @@ const PubkyBox = ({
pubkyName={pubkyName}
publicKey={publicKey}
isBackedUp={pubkyData.isBackedUp}
+ isBorrowed={pubkyData.sourceApp === 'to.bitkit'}
sessionsCount={sessionsCount}
/>
@@ -197,6 +201,7 @@ const styles = StyleSheet.create({
row: {
flexDirection: 'row',
flexWrap: 'nowrap',
+ alignItems: 'center',
},
backupContainer: {
flexDirection: 'row',
diff --git a/src/components/PubkyDetail/PubkyDetailCard.tsx b/src/components/PubkyDetail/PubkyDetailCard.tsx
index 95508f76..bc21482d 100644
--- a/src/components/PubkyDetail/PubkyDetailCard.tsx
+++ b/src/components/PubkyDetail/PubkyDetailCard.tsx
@@ -43,6 +43,7 @@ export const PubkyDetailCard = memo(
const buttonIcon = pubkyData.signedUp ? : undefined;
const buttonText = pubkyData.signedUp ? t('auth.authorize') : t('pubky.setup');
+ const isBorrowed = pubkyData.sourceApp === 'to.bitkit';
const showActionIcons = fontScale <= 1;
const shareIcon = showActionIcons ? : undefined;
@@ -70,17 +71,21 @@ export const PubkyDetailCard = memo(
testID="PubkyDetailShareButton"
onPress={onSharePress}
/>
+
+ {!isBorrowed && (
+
+ )}
+
-