diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml
index 5090b78fd..2696a40ed 100644
--- a/.github/workflows/e2e-android.yml
+++ b/.github/workflows/e2e-android.yml
@@ -62,6 +62,13 @@ jobs:
with:
node-version: 20
+ - name: Configure npm authentication
+ run: |
+ echo "" >> .yarnrc.yml
+ echo "npmScopes:" >> .yarnrc.yml
+ echo " synonymdev:" >> .yarnrc.yml
+ echo ' npmAuthToken: "${{ secrets.NPMJS_READ_RN_PUBKY }}"' >> .yarnrc.yml
+
- name: Use gradle caches
uses: actions/cache@v4
with:
diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml
index bca72f89b..bd236826c 100644
--- a/.github/workflows/e2e-ios.yml
+++ b/.github/workflows/e2e-ios.yml
@@ -53,6 +53,13 @@ jobs:
- name: Activate enviroment variables
run: cp .env.test.template .env
+ - name: Configure npm authentication
+ run: |
+ echo "" >> .yarnrc.yml
+ echo "npmScopes:" >> .yarnrc.yml
+ echo " synonymdev:" >> .yarnrc.yml
+ echo ' npmAuthToken: "${{ secrets.NPMJS_READ_RN_PUBKY }}"' >> .yarnrc.yml
+
- name: Yarn Install
run: yarn --no-audit --prefer-offline || yarn --no-audit --prefer-offline || yarn
env:
diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml
index 1b5d55ff5..58c4b7701 100644
--- a/.github/workflows/jest.yml
+++ b/.github/workflows/jest.yml
@@ -30,7 +30,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
- cache: 'yarn'
+
+ - name: Configure npm authentication
+ run: |
+ echo "" >> .yarnrc.yml
+ echo "npmScopes:" >> .yarnrc.yml
+ echo " synonymdev:" >> .yarnrc.yml
+ echo ' npmAuthToken: "${{ secrets.NPMJS_READ_RN_PUBKY }}"' >> .yarnrc.yml
- name: Install Node.js dependencies
run: yarn install || yarn install
diff --git a/.github/workflows/lint-check.yml b/.github/workflows/lint-check.yml
index 2c1579b1b..fe68edf7b 100644
--- a/.github/workflows/lint-check.yml
+++ b/.github/workflows/lint-check.yml
@@ -19,7 +19,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
- cache: 'yarn'
+
+ - name: Configure npm authentication
+ run: |
+ echo "" >> .yarnrc.yml
+ echo "npmScopes:" >> .yarnrc.yml
+ echo " synonymdev:" >> .yarnrc.yml
+ echo ' npmAuthToken: "${{ secrets.NPMJS_READ_RN_PUBKY }}"' >> .yarnrc.yml
- name: Install Node.js dependencies
run: yarn install
diff --git a/.github/workflows/type-check.yml b/.github/workflows/type-check.yml
index 7c37e1169..02748a548 100644
--- a/.github/workflows/type-check.yml
+++ b/.github/workflows/type-check.yml
@@ -19,7 +19,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
- cache: 'yarn'
+
+ - name: Configure npm authentication
+ run: |
+ echo "" >> .yarnrc.yml
+ echo "npmScopes:" >> .yarnrc.yml
+ echo " synonymdev:" >> .yarnrc.yml
+ echo ' npmAuthToken: "${{ secrets.NPMJS_READ_RN_PUBKY }}"' >> .yarnrc.yml
- name: Install Node.js dependencies
run: yarn install || yarn install
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index b5780de08..6d0edc493 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -1356,6 +1356,27 @@ PODS:
- Yoga
- react-native-netinfo (11.3.1):
- React-Core
+ - react-native-pubky (0.3.0):
+ - DoubleConversion
+ - glog
+ - hermes-engine
+ - RCT-Folly (= 2024.01.01.00)
+ - RCTRequired
+ - RCTTypeSafety
+ - React-Core
+ - React-debug
+ - React-Fabric
+ - React-featureflags
+ - React-graphics
+ - React-ImageManager
+ - React-NativeModulesApple
+ - React-RCTFabric
+ - React-rendererdebug
+ - React-utils
+ - ReactCodegen
+ - ReactCommon/turbomodule/bridging
+ - ReactCommon/turbomodule/core
+ - Yoga
- react-native-quick-base64 (2.1.2):
- DoubleConversion
- glog
@@ -1910,6 +1931,7 @@ DEPENDENCIES:
- "react-native-ldk (from `../node_modules/@synonymdev/react-native-ldk`)"
- react-native-mmkv (from `../node_modules/react-native-mmkv`)
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
+ - "react-native-pubky (from `../node_modules/@synonymdev/react-native-pubky`)"
- react-native-quick-base64 (from `../node_modules/react-native-quick-base64`)
- react-native-quick-crypto (from `../node_modules/react-native-quick-crypto`)
- react-native-restart (from `../node_modules/react-native-restart`)
@@ -2061,6 +2083,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-mmkv"
react-native-netinfo:
:path: "../node_modules/@react-native-community/netinfo"
+ react-native-pubky:
+ :path: "../node_modules/@synonymdev/react-native-pubky"
react-native-quick-base64:
:path: "../node_modules/react-native-quick-base64"
react-native-quick-crypto:
@@ -2213,6 +2237,7 @@ SPEC CHECKSUMS:
react-native-ldk: 1d25080cfadac349eab355725da66de140fbc7a8
react-native-mmkv: 7d0b6c2a79e73100b933f2947a9c8741d664e18b
react-native-netinfo: bdb108d340cdb41875c9ced535977cac6d2ff321
+ react-native-pubky: 9fd2633ee974bafa9b77e0cd59e2619a0d9d708d
react-native-quick-base64: f98f17faf04c9779faf726921a2b389d4775e8b6
react-native-quick-crypto: 12de8e1666ad3dab6339418c14f4a6de71716194
react-native-restart: 7595693413fe3ca15893702f2c8306c62a708162
@@ -2265,7 +2290,7 @@ SPEC CHECKSUMS:
SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d
sodium-react-native-direct: 8feb9a6d0d88ce65efa305d6cc774c11c62d9a15
SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef
- Yoga: a1d7895431387402a674fd0d1c04ec85e87909b8
+ Yoga: 2a45d7e59592db061217551fd3bbe2dd993817ae
ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5
PODFILE CHECKSUM: 8c2c3949d19327675be00d5f066e8eab99dd1e04
diff --git a/package.json b/package.json
index d249e3d05..a578a24e9 100644
--- a/package.json
+++ b/package.json
@@ -52,6 +52,7 @@
"@synonymdev/react-native-keychain": "8.2.2",
"@synonymdev/react-native-ldk": "0.0.152",
"@synonymdev/react-native-lnurl": "0.0.10",
+ "@synonymdev/react-native-pubky": "^0.3.0",
"@synonymdev/result": "0.0.2",
"@synonymdev/slashtags-keychain": "1.0.0",
"@synonymdev/slashtags-profile": "2.0.0",
diff --git a/src/navigation/bottom-sheet/BottomSheets.tsx b/src/navigation/bottom-sheet/BottomSheets.tsx
index 410fcc9c3..e9ec78a98 100644
--- a/src/navigation/bottom-sheet/BottomSheets.tsx
+++ b/src/navigation/bottom-sheet/BottomSheets.tsx
@@ -14,6 +14,7 @@ import PINNavigation from './PINNavigation';
import ReceiveNavigation from './ReceiveNavigation';
import SendNavigation from './SendNavigation';
import TreasureHuntNavigation from './TreasureHuntNavigation';
+import PubkyAuth from './PubkyAuth.tsx';
const BottomSheets = (): JSX.Element => {
const views = useAppSelector(viewControllersSelector);
@@ -31,6 +32,7 @@ const BottomSheets = (): JSX.Element => {
{views.receiveNavigation.isMounted && }
{views.sendNavigation.isMounted && }
{views.treasureHunt.isMounted && }
+ {views.pubkyAuth.isMounted && }
>
);
};
diff --git a/src/navigation/bottom-sheet/PubkyAuth.tsx b/src/navigation/bottom-sheet/PubkyAuth.tsx
new file mode 100644
index 000000000..8fd3bc304
--- /dev/null
+++ b/src/navigation/bottom-sheet/PubkyAuth.tsx
@@ -0,0 +1,253 @@
+import React, { memo, ReactElement, useCallback, useEffect, useMemo } from 'react';
+import { StyleSheet, View } from 'react-native';
+import { useTranslation } from 'react-i18next';
+
+import { BodyM, CaptionB, Text13UP, Title } from '../../styles/text';
+import BottomSheetWrapper from '../../components/BottomSheetWrapper';
+import SafeAreaInset from '../../components/SafeAreaInset';
+import Button from '../../components/buttons/Button';
+import BottomSheetNavigationHeader from '../../components/BottomSheetNavigationHeader';
+import { useAppSelector } from '../../hooks/redux';
+import {
+ useSnapPoints,
+} from '../../hooks/bottomSheet';
+import { viewControllerSelector } from '../../store/reselect/ui.ts';
+import { auth, parseAuthUrl } from '@synonymdev/react-native-pubky';
+import { getPubkySecretKey } from '../../utils/pubky';
+import { showToast } from '../../utils/notifications.ts';
+import { dispatch } from '../../store/helpers.ts';
+import { closeSheet } from '../../store/slices/ui.ts';
+import { CheckCircleIcon } from '../../styles/icons.ts';
+import Animated, { FadeIn } from 'react-native-reanimated';
+
+const defaultParsedUrl: PubkyAuthDetails = {
+ relay: '',
+ capabilities: [{
+ path: '',
+ permission: '',
+ }],
+ secret: '',
+};
+
+type Capability = {
+ path: string;
+ permission: string;
+};
+
+type PubkyAuthDetails = {
+ relay: string;
+ capabilities: Capability[];
+ secret: string;
+};
+
+const Permission = memo(({ capability, authSuccess }: { capability: Capability; authSuccess: boolean }): ReactElement => {
+ return (
+
+
+ {capability.path}
+
+
+
+ {capability.permission.includes('r') && (
+ Read
+
+ )}
+
+
+ {capability.permission.includes('w') && (
+ Write
+ )}
+
+
+
+ );
+});
+
+const PubkyAuth = (): ReactElement => {
+ const { t } = useTranslation('security');
+ const snapPoints = useSnapPoints('medium');
+ const { url = '' } = useAppSelector((state) => {
+ return viewControllerSelector(state, 'pubkyAuth');
+ });
+ const [parsed, setParsed] = React.useState(defaultParsedUrl);
+ const [authorizing, setAuthorizing] = React.useState(false);
+ const [authSuccess, setAuthSuccess] = React.useState(false);
+
+ useEffect(() => {
+ const fetchParsed = async (): Promise => {
+ const res = await parseAuthUrl(url);
+ if (res.isErr()) {
+ console.log(res.error.message);
+ return;
+ }
+ setParsed(res.value);
+ };
+ fetchParsed().then();
+
+ return (): void => {
+ setParsed(defaultParsedUrl);
+ setAuthorizing(false);
+ setAuthSuccess(false);
+ };
+ }, [url]);
+
+ const onAuthorize = useMemo(() => async (): Promise => {
+ try {
+ setAuthorizing(true);
+ const secretKey = await getPubkySecretKey();
+ if (secretKey.isErr()) {
+ showToast({
+ type: 'error',
+ title: t('authorization.pubky_secret_error_title'),
+ description: t('authorization.pubky_secret_error_description'),
+ });
+ setAuthorizing(false);
+ return;
+ }
+ const authRes = await auth(url, secretKey.value);
+ if (authRes.isErr()) {
+ showToast({
+ type: 'error',
+ title: t('authorization.pubky_auth_error_title'),
+ description: t('authorization.pubky_auth_error_description'),
+ });
+ setAuthorizing(false);
+ return;
+ }
+ setAuthSuccess(true);
+ setAuthorizing(false);
+ } catch (e) {
+ showToast({
+ type: 'error',
+ title: t('authorization.pubky_auth_error_title'),
+ description: JSON.stringify(e),
+ });
+ setAuthorizing(false);
+ }
+ }, [t, url]);
+
+ const onClose = useMemo(() => (): void => {
+ dispatch(closeSheet('pubkyAuth'));
+ }, []);
+
+ const Buttons = useCallback(() => {
+ if (authSuccess) {
+ return (
+
+ );
+ }
+ return (
+ <>
+
+
+ >
+ );
+ }, [authSuccess, authorizing, onAuthorize, onClose, t]);
+
+ const SuccessCircle = useCallback(() => {
+ if (authSuccess) {
+ return (
+
+
+
+ );
+ }
+ return null;
+ }, [authSuccess]);
+
+ return (
+
+
+
+ {t('authorization.claims')}
+ {parsed.relay}
+
+
+
+ {t('authorization.description')}
+
+
+
+ {t('authorization.requested_permissions')}
+ {parsed.capabilities.map((capability) => {
+ return ;
+ })}
+
+
+
+ {SuccessCircle()}
+
+
+ {Buttons()}
+
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ marginHorizontal: 32,
+ },
+ path: {
+ flex: 3,
+ },
+ buttonContainer: {
+ marginTop: 'auto',
+ flexDirection: 'row',
+ justifyContent: 'center',
+ },
+ authorizeButton: {
+ flex: 1,
+ margin: 5,
+ },
+ closeButton: {
+ flex: 1,
+ margin: 5,
+ backgroundColor: 'black',
+ borderWidth: 1,
+ },
+ buffer: {
+ height: 16,
+ },
+ row: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ permission: {
+ flex: 1,
+ },
+ permissionsRow: {
+ flex: 1,
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ },
+ circleIcon: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+});
+
+export default memo(PubkyAuth);
diff --git a/src/store/shapes/ui.ts b/src/store/shapes/ui.ts
index 694e8e716..502815e2e 100644
--- a/src/store/shapes/ui.ts
+++ b/src/store/shapes/ui.ts
@@ -19,6 +19,7 @@ export const defaultViewControllers: TUiState['viewControllers'] = {
orangeTicket: defaultViewController,
PINNavigation: defaultViewController,
profileAddDataForm: defaultViewController,
+ pubkyAuth: defaultViewController,
receiveNavigation: defaultViewController,
sendNavigation: defaultViewController,
timeRangePrompt: defaultViewController,
diff --git a/src/store/types/ui.ts b/src/store/types/ui.ts
index 6066d53b2..31041e4de 100644
--- a/src/store/types/ui.ts
+++ b/src/store/types/ui.ts
@@ -20,6 +20,7 @@ export type ViewControllerParamList = {
orangeTicket: { ticketId: string };
PINNavigation: { showLaterButton: boolean };
profileAddDataForm: undefined;
+ pubkyAuth: { url: string };
receiveNavigation: { receiveScreen: keyof ReceiveStackParamList } | undefined;
sendNavigation:
| { screen: keyof SendStackParamList }
diff --git a/src/utils/i18n/locales/en/security.json b/src/utils/i18n/locales/en/security.json
index 9966a7ea9..4acf4f256 100644
--- a/src/utils/i18n/locales/en/security.json
+++ b/src/utils/i18n/locales/en/security.json
@@ -310,5 +310,43 @@
},
"wiped_message": {
"string": "Bitkit has been reset and all wallet data has been deleted."
+ },
+ "authorization": {
+ "title": {
+ "string": "Authorization"
+ },
+ "description": {
+ "string": "Make sure you trust this service before granting permission to manage your data."
+ },
+ "authorize": {
+ "string": "Authorize"
+ },
+ "deny": {
+ "string": "Deny"
+ },
+ "authorizing": {
+ "string": "Authorizing..."
+ },
+ "success": {
+ "string": "Success"
+ },
+ "claims": {
+ "string": "This service claims to be"
+ },
+ "requested_permissions": {
+ "string": "Requested Permissions"
+ },
+ "pubky_secret_error_title": {
+ "string": "Pubky Error"
+ },
+ "pubky_secret_error_description": {
+ "string": "Unable to retrieve Pubky key"
+ },
+ "pubky_auth_error_title": {
+ "string": "Pubky Auth Error"
+ },
+ "pubky_auth_error_description": {
+ "string": "Unable to auth with Pubky service"
+ }
}
}
diff --git a/src/utils/pubky/index.ts b/src/utils/pubky/index.ts
new file mode 100644
index 000000000..b31633342
--- /dev/null
+++ b/src/utils/pubky/index.ts
@@ -0,0 +1,28 @@
+import {
+ getPrivateKeyFromPath,
+ getSelectedNetwork,
+} from '../wallet';
+import { EAvailableNetwork } from '../networks.ts';
+import { err, ok, Result } from '@synonymdev/result';
+import { getSha256 } from 'beignet';
+
+export const getPubkySecretKey = async ({
+ selectedNetwork = getSelectedNetwork(),
+ version = 1,
+}: {
+ selectedNetwork?: EAvailableNetwork;
+ version?: number;
+} = {}): Promise> => {
+ switch (version) {
+ case 1:
+ // TODO: Update path/key derivation accordingly
+ const privateKey = await getPrivateKeyFromPath({ path: "m/184'/0'/0'/0/0", selectedNetwork });
+ if (privateKey.isErr()) {
+ return err(privateKey.error.message);
+ }
+ const hash = getSha256(privateKey.value);
+ return ok(hash);
+ default:
+ return err('Invalid version');
+ }
+};
diff --git a/src/utils/scanner.ts b/src/utils/scanner.ts
index 69840e4b3..fca02dc3f 100644
--- a/src/utils/scanner.ts
+++ b/src/utils/scanner.ts
@@ -63,6 +63,7 @@ export enum EQRDataType {
slashFeedURL = 'slashFeedURL',
nodeId = 'nodeId',
treasureHunt = 'treasureHunt',
+ pubkyAuth = 'pubkyAuth',
//TODO add xpub, lightning node peer etc
}
@@ -82,7 +83,8 @@ export type QRData =
| TSlashTagUrl
| TSlashAuthUrl
| TSlashFeedUrl
- | TTreasureChestUrl;
+ | TTreasureChestUrl
+ | TPubkyAuthUrl;
export type TBitcoinUrl = {
qrDataType: EQRDataType.bitcoinAddress;
@@ -151,6 +153,11 @@ export type TTreasureChestUrl = {
chestId: string;
};
+export type TPubkyAuthUrl = {
+ qrDataType: EQRDataType.pubkyAuth;
+ url: string;
+};
+
/**
* Returns if the provided string is a valid Bech32m encoded string (taproot/p2tr address).
* @param {string} address
@@ -381,6 +388,11 @@ export const decodeQRData = async (
return ok([{ qrDataType: EQRDataType.slashFeedURL, url: data }]);
}
+ // Pubky Auth
+ if (data.startsWith('pubkyauth:')) {
+ return ok([{ qrDataType: EQRDataType.pubkyAuth, url: data }]);
+ }
+
let foundNetworksInQR: (
| TBitcoinUrl
| TLightningUrl
@@ -1043,6 +1055,12 @@ export const handleData = async ({
return ok({ type: EQRDataType.treasureHunt });
}
+ case EQRDataType.pubkyAuth: {
+ const { url } = data;
+ showBottomSheet('pubkyAuth', { url });
+ return ok({ type: EQRDataType.pubkyAuth });
+ }
+
default:
return err('Unable to read or interpret the provided data.');
}
diff --git a/src/utils/wallet/index.ts b/src/utils/wallet/index.ts
index 9f138903f..1ce9fd908 100644
--- a/src/utils/wallet/index.ts
+++ b/src/utils/wallet/index.ts
@@ -296,6 +296,7 @@ export const generateAddresses = async ({
/**
* Returns private key for the provided address data.
* @param {IAddress} addressData
+ * @param {string} path
* @param {EAvailableNetwork} [selectedNetwork]
* @return {Promise>}
*/
@@ -303,21 +304,12 @@ export const getPrivateKey = async ({
addressData,
selectedNetwork = getSelectedNetwork(),
}: {
- addressData: IAddress;
+ addressData?: IAddress;
selectedNetwork?: EAvailableNetwork;
}): Promise> => {
try {
- if (!addressGenerator) {
- const res = await setupAddressGenerator({});
- if (res.isErr()) {
- return err(res.error.message);
- }
- if (!addressGenerator) {
- return err('Unable to setup address generator.');
- }
- }
- return await addressGenerator.getPrivateKey({
- path: addressData.path,
+ return await getPrivateKeyFromPath({
+ path: addressData?.path,
selectedNetwork,
});
} catch (e) {
@@ -325,6 +317,33 @@ export const getPrivateKey = async ({
}
};
+export const getPrivateKeyFromPath = async ({
+ path,
+ selectedNetwork = getSelectedNetwork(),
+}: {
+ path?: string;
+ selectedNetwork?: EAvailableNetwork;
+}): Promise> => {
+ if (!path) {
+ return err('No address path specified.');
+ }
+ if (!addressGenerator) {
+ const res = await setupAddressGenerator({
+ selectedNetwork,
+ });
+ if (res.isErr()) {
+ return err(res.error.message);
+ }
+ if (!addressGenerator) {
+ return err('Unable to setup address generator.');
+ }
+ }
+ return await addressGenerator.getPrivateKey({
+ path,
+ selectedNetwork,
+ });
+};
+
const slashtagsPrimaryKeyKeyChainName = (seedHash: string = ''): string =>
'SLASHTAGS_PRIMARYKEY/' + seedHash;
diff --git a/yarn.lock b/yarn.lock
index 102b47a84..524fa0e49 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4968,6 +4968,18 @@ __metadata:
languageName: node
linkType: hard
+"@synonymdev/react-native-pubky@npm:^0.3.0":
+ version: 0.3.0
+ resolution: "@synonymdev/react-native-pubky@npm:0.3.0"
+ dependencies:
+ "@synonymdev/result": ^0.0.2
+ peerDependencies:
+ react: "*"
+ react-native: "*"
+ checksum: 9b77b129cc840ea6f4d0f297aaaf096b25ca8ac18ee0db567f015d6cba42de1b91505a8ccd3ebf141369298514392e02c41001fba7a259f441dd9c8a32fa2970
+ languageName: node
+ linkType: hard
+
"@synonymdev/result@npm:0.0.2, @synonymdev/result@npm:^0.0.2":
version: 0.0.2
resolution: "@synonymdev/result@npm:0.0.2"
@@ -6596,6 +6608,7 @@ __metadata:
"@synonymdev/react-native-keychain": 8.2.2
"@synonymdev/react-native-ldk": 0.0.152
"@synonymdev/react-native-lnurl": 0.0.10
+ "@synonymdev/react-native-pubky": ^0.3.0
"@synonymdev/result": 0.0.2
"@synonymdev/slashtags-keychain": 1.0.0
"@synonymdev/slashtags-profile": 2.0.0