Add PIN encryption/decryption of the private key

This commit is contained in:
Bojan Mojsilovic 2023-10-11 18:42:29 +02:00
parent 823f94df04
commit feff1269c4
9 changed files with 386 additions and 69 deletions

View File

@ -1,24 +1,15 @@
import { useIntl } from '@cookbook/solid-intl';
import { Component, createEffect, createSignal, For, Match, Show, Switch } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { zapNote } from '../../lib/zap';
import { userName } from '../../stores/profile';
import { toastZapFail, zapCustomOption } from '../../translations';
import { PrimalNote } from '../../types/primal';
import { debounce } from '../../utils';
import { Component, createEffect, createSignal } from 'solid-js';
import Modal from '../Modal/Modal';
import { useToastContext } from '../Toaster/Toaster';
import { login as tLogin, pin as tPin, actions as tActions } from '../../translations';
import styles from './CreatePinModal.module.scss';
import { hookForDev } from '../../lib/devTools';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonLink from '../Buttons/ButtonLink';
import { useNavigate } from '@solidjs/router';
import TextInput from '../TextInput/TextInput';
import ButtonSecondary from '../Buttons/ButtonSecondary';
import { encryptWithPin, setCurrentPin } from '../../lib/PrimalNostr';
const CreatePinModal: Component<{
id?: string,
@ -29,23 +20,29 @@ const CreatePinModal: Component<{
}> = (props) => {
const intl = useIntl();
const navigate = useNavigate();
let pinInput: HTMLInputElement | undefined;
const [pin, setPin] = createSignal('');
const [rePin, setRePin] = createSignal('');
const encriptWithPin = () => {
const val = props.valueToEncrypt || '<NA>';
return `${val}_${pin()}`;
const encWithPin = async () => {
const val = props.valueToEncrypt || '';
const enc = await encryptWithPin(pin(), val);
return enc;
};
const onSetPin = () => {
// Verify pin
const onSetPin = async() => {
if (!isValidPin || !isValidRePin()) return;
// Encrypt private key
const enc = await encWithPin();
// Save PIN for the session
setCurrentPin(pin());
// Execute callback
props.onPinApplied && props.onPinApplied(encriptWithPin());
props.onPinApplied && props.onPinApplied(enc);
};
const onOptout = () => {

View File

@ -0,0 +1,94 @@
.modal {
position: fixed;
width: 452px;
color: var(--text-secondary);
background-color: var(--background-site);
background: linear-gradient(var(--background-site),
var(--background-site)) padding-box,
var(--brand-gradient) border-box;
border: 1px solid transparent;
border-radius: 6px;
display: flex;
flex-direction: column;
padding: 22px;
.xClose {
background: none;
border: none;
margin: 0;
padding: 0;
width: fit-content;
position: absolute;
top: 18px;
right: 18px;
.iconClose {
width: 14px;
height: 14px;
display: inline-block;
margin: 0px 0px;
background-color: var(--text-secondary);
-webkit-mask: url(../../assets/icons/close.svg) no-repeat center;
mask: url(../../assets/icons/close.svg) no-repeat center;
}
&:hover {
.iconClose {
background-color: var(--text-primary);
}
}
&:focus {
box-shadow: none;
}
}
.title {
font-weight: 800;
font-size: 18px;
line-height: 18px;
color: var(--text-secondary);
text-transform: uppercase;
margin-bottom: 20px;
}
.description {
color: var(--text-secondary);
font-size: 16px;
font-weight: 400;
line-height: 20px;
margin-bottom: 28px;
}
.inputs {
display: flex;
justify-content: flex-start;
margin-bottom: 4px;
> div {
margin-bottom: 4px;
}
}
.actions {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
button {
width: fit-content;
min-width: 192px;
margin: 0px;
padding: 10px;
}
}
.alternative {
color: var(--text-secondary);
font-size: 14px;
font-weight: 400;
line-height: 24px;
}
}

View File

@ -0,0 +1,121 @@
import { useIntl } from '@cookbook/solid-intl';
import { Component, createEffect, createSignal, For, Match, Show, Switch } from 'solid-js';
import { useAccountContext } from '../../contexts/AccountContext';
import { useSettingsContext } from '../../contexts/SettingsContext';
import { zapNote } from '../../lib/zap';
import { userName } from '../../stores/profile';
import { toastZapFail, zapCustomOption } from '../../translations';
import { PrimalNote } from '../../types/primal';
import { debounce } from '../../utils';
import Modal from '../Modal/Modal';
import { useToastContext } from '../Toaster/Toaster';
import { base64 } from '@scure/base';
import { nip19, utils } from 'nostr-tools';
import { login as tLogin, pin as tPin, actions as tActions } from '../../translations';
import styles from './EnterPinModal.module.scss';
import { hookForDev } from '../../lib/devTools';
import ButtonPrimary from '../Buttons/ButtonPrimary';
import ButtonLink from '../Buttons/ButtonLink';
import { useNavigate } from '@solidjs/router';
import TextInput from '../TextInput/TextInput';
import ButtonSecondary from '../Buttons/ButtonSecondary';
import { decryptWithPin, encryptWithPin, setCurrentPin } from '../../lib/PrimalNostr';
const EnterPinModal: Component<{
id?: string,
open?: boolean,
valueToDecrypt?: string,
onSuccess?: (decryptedValue: string) => void,
onAbort?: () => void,
}> = (props) => {
const intl = useIntl();
const toast = useToastContext();
let pinInput: HTMLInputElement | undefined;
const [pin, setPin] = createSignal('');
const decWithPin = async () => {
const val = props.valueToDecrypt || '';
const dec = await decryptWithPin(pin(), val);
// console.log('ENCODED: ', dec);
// console.log('PIN: ', pin());
// console.log('DECODE: ', decryptWithPin);
return dec;
};
const onConfirm = async() => {
if (!isValidPin) return;
// Decrypt private key
const enc = await decWithPin();
try {
const decoded = nip19.decode(enc);
if (decoded.type !== 'nsec' || !decoded.data) {
throw('invalid-nsec-decoded');
}
// Save PIN for the session
setCurrentPin(pin());
// Execute callback
props.onSuccess && props.onSuccess(enc);
} catch(e) {
console.log('Failed to decode nsec: ', e);
toast?.sendWarning('PIN is incorrect');
}
};
createEffect(() => {
if (props.open) {
pinInput?.focus();
}
});
const isValidPin = () => {
return pin().length > 3;
}
return (
<Modal open={props.open}>
<div id={props.id} class={styles.modal}>
<button class={styles.xClose} onClick={props.onAbort}>
<div class={styles.iconClose}></div>
</button>
<div class={styles.title}>
{intl.formatMessage(tPin.enterTitle)}
</div>
<div class={styles.inputs}>
<TextInput
type="password"
ref={pinInput}
value={pin()}
onChange={(val: string) => setPin(val)}
label={intl.formatMessage(tPin.enter)}
validationState={pin().length === 0 || isValidPin() ? 'valid' : 'invalid'}
errorMessage={intl.formatMessage(tPin.invalidRePin)}
/>
</div>
<div class={styles.actions}>
<ButtonPrimary
onClick={onConfirm}
disabled={!isValidPin()}
>
{intl.formatMessage(tActions.createPin)}
</ButtonPrimary>
</div>
</div>
</Modal>
);
}
export default hookForDev(EnterPinModal);

View File

@ -33,7 +33,6 @@ const LoginModal: Component<{
if (!isValidNsec()) return;
account?.actions.setSec(sec);
storeSec(sec);
setStep(() => 'pin');
};
@ -57,7 +56,6 @@ const LoginModal: Component<{
if (key.startsWith('nsec')) {
try {
console.log('KEY: ', key)
const decoded = nip19.decode(key);
return decoded.type === 'nsec' && decoded.data;

View File

@ -321,3 +321,6 @@ export const algoNpub ='npub1tkpg9lyfgy83c4mgrgkrhzl90t732ekzvt73m6658xva88g5rj6
export const specialAlgos = ['primal_spam', 'primal_nsfw'];
export const profileContactListPage = 50;
export const pinEncodePrefix = 'prpec';
export const pinEncodeIVSeparator = '?iv=';

View File

@ -18,7 +18,7 @@ import {
PrimalNote,
PrimalUser,
} from '../types/primal';
import { Kind, relayConnectingTimeout } from "../constants";
import { Kind, pinEncodePrefix, relayConnectingTimeout } from "../constants";
import { isConnected, refreshSocketListeners, removeSocketListeners, socket, subscribeTo, reset } from "../sockets";
import { sendContacts, sendLike, sendMuteList, triggerImportEvents } from "../lib/notes";
// @ts-ignore Bad types in nostr-tools
@ -29,6 +29,7 @@ import { getStorage, readSecFromStorage, saveFollowing, saveLikes, saveMuted, sa
import { connectRelays, connectToRelay, getDefaultRelays, getPreConfiguredRelays } from "../lib/relays";
import { getPublicKey } from "../lib/nostrAPI";
import { generateKeys } from "../lib/PrimalNostr";
import EnterPinModal from "../components/EnterPinModal/EnterPinModal";
export type AccountContextStore = {
likes: string[],
@ -54,6 +55,7 @@ export type AccountContextStore = {
allowlist: string[],
allowlistSince: number,
sec: string | undefined,
showPin: string,
actions: {
showNewNoteForm: () => void,
hideNewNoteForm: () => void,
@ -103,6 +105,7 @@ const initialData = {
allowlist: [],
allowlistSince: 0,
sec: undefined,
showPin: '',
};
export const AccountContext = createContext<AccountContextStore>();
@ -124,8 +127,8 @@ export function AccountProvider(props: { children: JSXElement }) {
updateStore('sec', () => sec);
const pubkey = nostrGetPubkey(decoded.data);
setPublicKey(pubkey);
getUserProfiles([pubkey], `user_profile_${APP_ID}`);
}
}
const setPublicKey = (pubkey: string | undefined) => {
@ -268,7 +271,12 @@ export function AccountProvider(props: { children: JSXElement }) {
const sec = readSecFromStorage();
if (sec) {
setSec(sec);
if (sec.startsWith(pinEncodePrefix)) {
updateStore('showPin', () => sec);
}
else {
setSec(sec);
}
} else {
updateStore('publicKey', () => undefined);
}
@ -1213,6 +1221,15 @@ const [store, updateStore] = createStore<AccountContextStore>({
return (
<AccountContext.Provider value={store}>
{props.children}
<EnterPinModal
open={store.showPin.length > 0}
valueToDecrypt={store.showPin}
onSuccess={(sec: string) => {
setSec(sec);
updateStore('showPin', () => '');
}}
onAbort={() => updateStore('showPin', () => '')}
/>
</AccountContext.Provider>
);
}

View File

@ -1,7 +1,13 @@
// @ts-ignore Bad types in nostr-tools
import { generatePrivateKey, getPublicKey, nip04, getSignature, getEventHash, validateEvent, verifySignature } from 'nostr-tools';
import { generatePrivateKey, getPublicKey, nip04, getSignature, getEventHash, validateEvent, verifySignature, nip19 } from 'nostr-tools';
import { NostrExtension, NostrRelayEvent, NostrRelays, NostrRelaySignedEvent } from '../types/primal';
import { readSecFromStorage, storeSec } from './localStore';
import { base64 } from '@scure/base';
import { pinEncodeIVSeparator, pinEncodePrefix } from '../constants';
import { createSignal } from 'solid-js';
export const [currentPin, setCurrentPin] = createSignal('');
export const generateKeys = (forceNewKey?: boolean) => {
const sec = forceNewKey ?
@ -12,64 +18,137 @@ export const generateKeys = (forceNewKey?: boolean) => {
return { sec, pubkey };
};
export const encryptWithPin = async (pin: string, text: string) => {
try {
const crypto = window.crypto;
if (!crypto) {
throw('not-secure-env');
}
const utf8Encoder = new TextEncoder();
const key = await crypto.subtle.digest('SHA-256', utf8Encoder.encode(pin));
let iv = Uint8Array.from(crypto.getRandomValues(new Uint8Array(16)));
let plaintext = utf8Encoder.encode(text)
let cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['encrypt'])
let ciphertext = await crypto.subtle.encrypt({ name: 'AES-CBC', iv }, cryptoKey, plaintext)
let ctb64 = base64.encode(new Uint8Array(ciphertext))
let ivb64 = base64.encode(new Uint8Array(iv.buffer))
return `${pinEncodePrefix}${ctb64}${pinEncodeIVSeparator}${ivb64}`
} catch(e) {
console.log('Failed to encrypt with PIN: ', e);
return '';
}
};
export const decryptWithPin = async (pin: string, cipher: string) => {
try {
if (!cipher.startsWith(pinEncodePrefix)) {
throw('bad-cipher');
}
const crypto = window.crypto;
if (!crypto) {
throw('not-secure-env');
}
const utf8Encoder = new TextEncoder();
const utf8Decoder = new TextDecoder('utf-8');
const data = cipher.slice(pinEncodePrefix.length);
const key = await crypto.subtle.digest('SHA-256', utf8Encoder.encode(pin));
let [ctb64, ivb64] = data.split(pinEncodeIVSeparator)
let cryptoKey = await crypto.subtle.importKey('raw', key, { name: 'AES-CBC' }, false, ['decrypt'])
let ciphertext = base64.decode(ctb64)
let iv = base64.decode(ivb64)
let plaintext = await crypto.subtle.decrypt({ name: 'AES-CBC', iv }, cryptoKey, ciphertext)
let text = utf8Decoder.decode(plaintext)
return text
} catch(e) {
console.log('Failed to decrypt with PIN: ', e);
return '';
}
};
export const PrimalNostr: (pk?: string) => NostrExtension = (pk?: string) => {
let sec: string = pk || readSecFromStorage() || generatePrivateKey();
const getSec = async () => {
let sec: string = pk || readSecFromStorage() || generatePrivateKey();
let pubkey: string = getPublicKey(sec);
if (sec.startsWith(pinEncodePrefix)) {
sec = await decryptWithPin(currentPin(), sec);
}
const decoded = nip19.decode(sec);
storeSec(sec);
if (decoded.type !== 'nsec' || !decoded.data) {
throw('invalid-nsec');
}
sec = decoded.data;
return sec.length > 0 ? sec : undefined;
}
const gPk: () => Promise<string> = async () => {
const sec = await getSec();
if (!sec) throw('pubkey-no-nsec');
return await getPublicKey(sec);
};
const gPk: () => Promise<string> = () => new Promise<string>(r => r(getPublicKey(sec)));
const gRl: () => Promise<NostrRelays> = () => new Promise<NostrRelays>((resolve) => {resolve({})});
const encrypt: (pubkey: string, message: string) => Promise<string> =
(pubkey, message) => new Promise((rs, rj) => {
try {
rs(nip04.encrypt(sec, pubkey, message));
} catch(e) {
console.log('Failed to encript (PrimalNostr): ', e);
rj();
}
});
async (pubkey, message) => {
const sec = await getSec();
if (!sec) throw('encrypt-no-nsec');
return await nip04.encrypt(sec, pubkey, message);
};
const decrypt: (pubkey: string, message: string) => Promise<string> =
(pubkey, message) => new Promise((rs, rj) => {
try {
rs(nip04.decrypt(sec, pubkey, message));
} catch(e) {
console.log('Failed to decrypt (PrimalNostr): ', e);
rj();
}
});
async (pubkey, message) => {
const sec = await getSec();
if (!sec) throw('decrypt-no-nsec');
return await nip04.decrypt(sec, pubkey, message);
};
const signEvent = async (event: NostrRelayEvent) => {
const sec = await getSec();
if (!sec) throw('sign-no-nsec');
const pubkey: string = await gPk();
let evt = { ...event, pubkey };
// @ts-ignore
evt.id = getEventHash(evt);
// @ts-ignore
evt.sig = getSignature(evt, sec);
const isValid = validateEvent(evt);
const isVerified = verifySignature(evt);
if (!isValid) throw('event-not-valid');
if (!isVerified) throw('event-sig-not-verified');
return evt as NostrRelaySignedEvent;
};
return {
sec,
pubkey,
getPublicKey: gPk,
getRelays: gRl,
nip04: {
encrypt,
decrypt,
},
signEvent: (event: NostrRelayEvent) => {
return new Promise<NostrRelaySignedEvent>((resolve, reject) => {
try {
const id = getEventHash(event);
const sig = getSignature(event, sec);
const signed: NostrRelaySignedEvent = { ...event, id, sig, pubkey };
const isValid = validateEvent(signed);
const isVerified = verifySignature(signed);
if (!isValid) throw('event-not-valid');
if (!isVerified) throw('event-sig-not-verified');
resolve(signed);
} catch(e) {
reject(e);
}
});
},
signEvent,
};
};

View File

@ -48,13 +48,23 @@ export const pin = {
title: {
id: 'pin.title',
defaultMessage: 'Create Pin',
description: 'Create Pin ',
description: 'Create Pin modal title',
},
description: {
id: 'pin.description',
defaultMessage: 'Create a PIN to secure your account. You will need to enter this PIN every time you login to the Primal web app:',
description: 'Label describing what the pin is used for',
},
enter: {
id: 'pin.enter',
defaultMessage: 'Enter your PIN to login: ',
description: 'Label instructing the user to enter the pin',
},
enterTitle: {
id: 'pin.enterTitle',
defaultMessage: 'Enter Pin',
description: 'Enter Pin modal title',
},
reEnter: {
id: 'pin.reEnter',
defaultMessage: 'Re-type your PIN:',

View File

@ -334,8 +334,6 @@ interface SendPaymentResponse {
}
export type NostrExtension = {
sec?: string,
pubkey?: string,
getPublicKey: () => Promise<string>,
getRelays: () => Promise<NostrRelays>,
signEvent: (event: NostrRelayEvent) => Promise<NostrRelaySignedEvent>,