1 import type { ChangeEvent, FormEvent } from 'react';
2 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3 import { flushSync } from 'react-dom';
5 import { Point, utils } from '@noble/ed25519';
6 import { c } from 'ttag';
8 import { Button, CircleLoader, Href } from '@proton/atoms';
9 import Alert from '@proton/components/components/alert/Alert';
10 import Row from '@proton/components/components/container/Row';
11 import Icon from '@proton/components/components/icon/Icon';
12 import Radio from '@proton/components/components/input/Radio';
13 import TextArea from '@proton/components/components/input/TextArea';
14 import Info from '@proton/components/components/link/Info';
15 import ConfirmModal from '@proton/components/components/modal/Confirm';
16 import Option from '@proton/components/components/option/Option';
17 import Toggle from '@proton/components/components/toggle/Toggle';
18 import downloadFile from '@proton/shared/lib/helpers/downloadFile';
19 import { base64StringToUint8Array, uint8ArrayToBase64String } from '@proton/shared/lib/helpers/encoding';
20 import { readableTime } from '@proton/shared/lib/helpers/time';
21 import type { Logical } from '@proton/shared/lib/vpn/Logical';
23 import { ErrorButton, InputFieldTwo, SelectTwo, useModalTwoStatic } from '../../../components';
24 import Details from '../../../components/container/Details';
25 import Summary from '../../../components/container/Summary';
26 import { getObjectKeys } from '../../../helpers';
27 import { getCountryOptions, getLocalizedCountryByAbbr } from '../../../helpers/countries';
37 } from '../../../hooks';
38 import { SettingsParagraph, SettingsSectionWide } from '../../account';
39 import type { Certificate } from '../Certificate';
40 import { CATEGORY } from '../OpenVPNConfigurationSection/ConfigsTable';
41 import OpenVPNConfigurationSection from '../OpenVPNConfigurationSection/OpenVPNConfigurationSection';
42 import { getFlagSvg } from '../flag';
43 import type { CertificateDTO, CertificateDeletionParams, CertificateGenerationParams } from './Certificate';
44 import type { KeyPair } from './KeyPair';
45 import WireGuardCreationModal from './WireGuardCreationModal';
46 import { deleteCertificates, generateCertificate, getKey, queryVPNClientConfig } from './api';
47 import { CURVE } from './curve';
48 import type { FeatureFlagsConfig, FeatureOption, FeatureSelection, FeaturesConfig, FeaturesValues } from './feature';
51 formatFeatureShortName,
54 initialFeaturesConfig,
57 import { normalize } from './normalize';
58 import useCertificates from './useCertificates';
76 interface ExtraCertificateFeatures {
77 peerName: Peer['name'];
78 peerPublicKey: Peer['publicKey'];
84 const isExtraFeatureKey = getKeyOfCheck<ExtraCertificateFeatures>(['peerIp', 'peerName', 'peerPublicKey', 'platform']);
86 // From https://github.com/paulmillr/noble-ed25519/blob/d87d6e953304c9d4dbfb275e8e67a0c975d3262b/index.js
87 const bytesToNumberLE = (uint8a: Uint8Array) => {
88 let value = BigInt(0);
90 for (let i = 0; i < uint8a.length; i++) {
91 value += BigInt(uint8a[i]) << (BigInt(8) * BigInt(i));
97 const unarmor = (key: string): string => `\n${key}\n`.replace(/\n---.+\n/g, '').replace(/\s/g, '');
99 const randomPrivateKey = () => {
101 throw new Error('BigInt not supported');
107 const b32 = crypto.getRandomValues(new Uint8Array(32));
108 const num = bytesToNumberLE(b32);
110 if (num > BigInt(1) && num < CURVE.n) {
115 throw new Error('Valid private key was not found in 1024 iterations. PRNG is broken');
118 const getPublicKey = async (privateKey: Uint8Array): Promise<Uint8Array> => {
119 const key = await Point.fromPrivateKey(privateKey);
121 return key.toRawBytes();
124 const getFeatureLink = (feature: FeatureOption<any>) =>
128 <Href className="text-no-bold" href={feature.url}>{c('Info').t`Learn more`}</Href>
134 const getConfigTemplate = (
135 interfacePrivateKey: string,
136 name: string | undefined,
137 features: Partial<FeaturesValues & ExtraCertificateFeatures> | undefined,
139 ) => `[Interface]${name ? `\n# Key for ${name}` : ''}${getObjectKeys(features)
141 isExtraFeatureKey(key) ? '' : `\n# ${formatFeatureShortName(key)} = ${formatFeatureValue(features, key)}`
144 PrivateKey = ${interfacePrivateKey}
145 Address = 10.2.0.2/32
149 # ${features?.peerName || peer.name}
150 PublicKey = ${features?.peerPublicKey || peer.publicKey}
151 AllowedIPs = ${features?.platform === PLATFORM.WINDOWS ? '0.0.0.0/1, 128.0.0.0/1' : '0.0.0.0/0'}
152 Endpoint = ${features?.peerIp || peer.ip}:51820`;
154 const privateKeyPlaceholder = '*****';
156 const getCertificateModel = (
157 certificateDto: CertificateDTO & { id?: string },
162 if (!id && !certificateDto.id) {
163 certificateDto.id = `c${Date.now()}-${Math.random()}`;
166 const name = certificateDto?.DeviceName;
167 const features = certificateDto?.Features;
170 id: `${id || certificateDto.id}`,
173 serialNumber: certificateDto.SerialNumber,
174 privateKey: privateKey || privateKeyPlaceholder,
175 publicKey: certificateDto.ClientKey,
176 publicKeyFingerprint: certificateDto.ClientKeyFingerprint,
177 expirationTime: certificateDto.ExpirationTime,
178 config: getConfigTemplate(privateKey || privateKeyPlaceholder, name, features, peer),
182 const paginationSize = 50;
184 const formatServerName = (bestServerName: string, alt: (code: string) => string | undefined) => {
185 const countryCode = bestServerName.split(/#/g)[0];
186 const flag = getFlagSvg(countryCode);
190 {flag && <img width={20} className="mx-2 border" src={flag} alt={alt(countryCode)} />}
191 <strong className="align-middle">{bestServerName}</strong>
196 const getX25519PrivateKey = async (privateKey: string): Promise<string> => {
197 const sha512 = (await utils.sha512(base64StringToUint8Array(privateKey))).slice(0, 32);
201 return uint8ArrayToBase64String(sha512);
204 const WireGuardConfigurationSection = () => {
205 const [platform, setPlatform] = useState(PLATFORM.ANDROID);
206 const [featuresConfig, setFeaturesConfig] = useState<FeaturesConfig>(initialFeaturesConfig);
207 const api = useApi();
208 const [peer, setPeer] = useState<Peer>({
214 const certificateCacheRef = useRef<Record<string, Certificate>>({});
215 const certificateCache = certificateCacheRef.current;
216 const [logical, setLogical] = useState<Logical | undefined>();
217 const [creationModal, handleShowCreationModal] = useModalTwoStatic(WireGuardCreationModal);
218 const [creating, setCreating] = useState<boolean>(false);
219 const [removing, setRemoving] = useState<Record<string, boolean>>({});
220 const [removedCertificates, setRemovedCertificates] = useState<string[]>([]);
221 const [currentCertificate, setCurrentCertificate] = useState<string | undefined>();
222 const [certificates, setCertificates] = useState<Certificate[]>([]);
223 const [{ hasPaidVpn }] = useUser();
224 const [userSettings] = useUserSettings();
225 const { result, loading: vpnLoading, fetch: fetchUserVPN } = useUserVPN();
226 const userVPN = result?.VPN;
227 const nameInputRef = useRef<HTMLInputElement>(null);
228 const { createModal } = useModals();
229 const { createNotification } = useNotifications();
230 const { result: clientConfig = { FeatureFlags: {} as FeatureFlagsConfig } } = useApiResult<
231 { FeatureFlags: FeatureFlagsConfig },
232 typeof queryVPNClientConfig
233 >(queryVPNClientConfig, []);
235 loading: logicalsLoading,
236 result: logicalsResult = { LogicalServers: [] },
237 fetch: fetchLogicals,
238 } = useVPNLogicals();
239 const [limit, setLimit] = useState(paginationSize);
240 const { loading: certificatesLoading, result: certificatesResult, moreToLoad } = useCertificates(limit);
242 const countryOptions = getCountryOptions(userSettings);
244 const logicalInfoLoading = logicalsLoading || vpnLoading;
245 const maxTier = userVPN?.MaxTier || 0;
246 const logicals = useMemo(
248 ((!logicalInfoLoading && logicalsResult.LogicalServers) || [])
251 Servers: (logical.Servers || []).filter((server) => server.X25519PublicKey),
253 .filter((logical) => logical.Servers.length),
254 [logicalInfoLoading, logicalsResult.LogicalServers]
256 const bestLogicals = logicals
257 .filter((server) => server.Tier <= maxTier && (server.Features & 3) === 0)
258 .sort((a, b) => a.Score - b.Score);
259 const bestLogical = bestLogicals[0];
260 const bestServerName = bestLogical?.Name;
261 const formattedBestServerName = bestServerName
262 ? formatServerName(bestServerName, (code) => getLocalizedCountryByAbbr(code, countryOptions))
265 const getCertificates = (): Certificate[] => {
266 certificatesResult.forEach((certificateDto) => {
268 removedCertificates.indexOf(certificateDto.ClientKeyFingerprint) !== -1 ||
269 certificateDto.ExpirationTime <
270 (certificateCache[certificateDto.ClientKeyFingerprint]?.expirationTime || 0)
275 certificateCache[certificateDto.ClientKeyFingerprint] = getCertificateModel(certificateDto, peer);
278 certificates.forEach((certificate) => {
280 removedCertificates.indexOf(certificate.publicKeyFingerprint) !== -1 ||
281 certificate.expirationTime < (certificateCache[certificate.publicKeyFingerprint]?.expirationTime || 0)
286 certificateCache[certificate.publicKeyFingerprint] = certificate;
289 return Object.values(certificateCache);
292 const setFeature = <K extends keyof FeaturesConfig>(key: K, value: FeaturesConfig[K]['value']) => {
296 ...featuresConfig[key],
302 const getKeyPair = async (): Promise<{ privateKey: string; publicKey: string }> => {
304 const privateKey = randomPrivateKey();
307 privateKey: uint8ArrayToBase64String(privateKey),
308 publicKey: uint8ArrayToBase64String(await getPublicKey(privateKey)),
313 'Fallback to server-side generated key. Upgrade to a modern browser, to generate right from your device.'
316 const { PrivateKey, PublicKey } = await api<KeyPair>(getKey());
318 return { privateKey: unarmor(PrivateKey), publicKey: unarmor(PublicKey) };
322 const getToggleCallback = (certificate: Certificate) => (event: ChangeEvent<HTMLDetailsElement>) => {
323 if (!event?.target?.hasAttribute('open')) {
327 if (certificate.id === currentCertificate) {
331 setCurrentCertificate(certificate.id);
334 const queryCertificate = (
336 deviceName?: string | null | undefined,
337 features?: Record<string, string | number | boolean | null> | undefined,
338 options: Partial<CertificateGenerationParams> = {}
339 ): Promise<CertificateDTO> =>
341 generateCertificate({
342 ClientPublicKey: publicKey,
344 DeviceName: deviceName,
350 const getFeatureKeys = () =>
351 getObjectKeys(featuresConfig).filter(
352 (key) => maxTier >= (featuresConfig[key].tier || 0) && clientConfig?.FeatureFlags?.[clientConfigKeys[key]]
354 const getFeatureValues = useCallback(
355 (addedPeer?: Peer) => {
356 const peerFeatures = addedPeer || peer;
357 const label = peerFeatures?.label;
359 return Object.assign(
360 label ? { Bouncing: label } : {},
362 getFeatureKeys().map((key) => [
364 ((featuresConfig[key].transform || ((v: any) => v)) as <T>(value: T) => T)(
365 featuresConfig[key].value
371 peerName: peerFeatures.name,
372 peerIp: peerFeatures.ip,
373 peerPublicKey: peerFeatures.publicKey,
379 [featuresConfig, peer, platform]
382 const getDownloadCallback = (certificate: Certificate) => () => {
387 const serverName = `${certificate?.features?.peerName || peer.name}`.substring(0, 20);
390 new Blob([certificate.config || '']),
391 normalize((certificate.name || 'wg') + '-' + serverName) + '.conf'
395 const add = async (addedPeer?: Peer, silent = false) => {
403 const serverName = addedPeer?.name;
405 if (!silent && serverName) {
406 handleShowCreationModal({
408 serverName: serverName,
413 handleShowCreationModal({ open: false });
419 const { privateKey, publicKey } = await getKeyPair();
420 const x25519PrivateKey = await getX25519PrivateKey(privateKey);
421 const deviceName = nameInputRef?.current?.value || '';
422 const certificate = await queryCertificate(publicKey, deviceName, getFeatureValues(addedPeer));
424 if (!certificate.DeviceName) {
425 certificate.DeviceName = deviceName;
428 const newCertificate = getCertificateModel(certificate, peer, x25519PrivateKey);
429 const id = newCertificate.id;
430 let name = newCertificate.name || newCertificate.publicKeyFingerprint || newCertificate.publicKey;
432 if (name.length > 46) {
433 name = name.substring(0, 21) + '…' + name.substring(name.length - 21);
437 const downloadCallback = getDownloadCallback(newCertificate);
439 handleShowCreationModal({
441 serverName: serverName,
442 // translator: name a name given by the user to a config file
443 text: c('Success notification')
444 .t`Config "${name}" created, note that the private key is not stored and won't be shown again, you should copy or download this config.`,
445 config: newCertificate?.config || '',
448 handleShowCreationModal({ open: false });
451 handleShowCreationModal({ open: false });
457 setCurrentCertificate(id);
458 setCertificates([...(certificates || []), newCertificate]);
461 document.querySelector(`[data-certificate-id="${id}"]`)?.scrollIntoView();
463 if (nameInputRef?.current) {
464 nameInputRef.current.value = '';
467 if (!silent && serverName) {
468 handleShowCreationModal({ open: false });
478 const selectLogical = useCallback(
479 async (logical: Logical, silent = false, doAdd = false) => {
480 const servers = logical?.Servers || [];
481 const numberOfServers = servers.length;
482 const server = servers[Math.floor(Math.random() * numberOfServers)];
483 const serverName = logical?.Name;
484 let addPromise: Promise<void> | undefined = undefined;
489 publicKey: `${server.X25519PublicKey}`,
491 label: server.Label || '',
495 addPromise = add(newPeer, silent);
498 if (peer.ip !== server.EntryIP) {
503 setLogical({ ...logical });
509 [peer, getFeatureValues, platform, creating]
512 if (!logicalInfoLoading && logicals.length && typeof logical === 'undefined') {
513 void selectLogical(bestLogical, true);
516 const createWithLogical = useCallback(
517 (logical: Logical, silent = false) => selectLogical(logical, silent, true),
521 const revokeCertificate = (name: string) => {
523 // translator: name is arbitrary name given by the user or a random key if not set
524 text: c('Success notification').t`Certificate ${name} revoked`,
528 const confirmRevocation = async (name: string) => {
529 return new Promise<void>((resolve, reject) => {
533 title={c('Title').t`Revoke certificate`}
535 confirm={<ErrorButton type="submit">{c('Action').t`Delete`}</ErrorButton>}
538 <Alert className="mb-4" type="info">
540 // translator: name is arbitrary name given by the user or a random key if not set
541 c('Info').t`Revoke certificate ${name}`
544 <Alert className="mb-4" type="error">
545 {c('Alter').t`This will disconnect all the routers and clients using this certificate.`}
552 const getDeleteFilter = (certificate: Certificate): CertificateDeletionParams => {
553 if (certificate.serialNumber) {
554 return { SerialNumber: certificate.serialNumber };
557 if (certificate.publicKeyFingerprint) {
558 return { ClientPublicKeyFingerprint: certificate.publicKeyFingerprint };
561 return { ClientPublicKey: certificate.publicKey };
564 const askForRevocation = (certificate: Certificate) => async () => {
565 const key = certificate.publicKeyFingerprint || certificate.publicKey || certificate.id;
573 const newValues = { ...removing };
574 delete newValues[key];
576 setRemoving(newValues);
580 const name = certificate.name || certificate.publicKeyFingerprint || certificate.publicKey || '';
581 await confirmRevocation(name);
582 const { Count } = await api(deleteCertificates(getDeleteFilter(certificate)));
587 // translator: name is arbitrary name given by the user or a random key if not set
588 text: c('Error notification').t`Certificate ${name} not found or already revoked`,
595 delete certificateCache[certificate.publicKeyFingerprint];
596 setRemovedCertificates([...removedCertificates, certificate.publicKeyFingerprint]);
597 setCertificates(certificates.filter((c) => c.id !== certificate.id));
598 revokeCertificate(name);
606 const handleFormSubmit = (e: FormEvent) => {
609 return createWithLogical(bestLogical);
612 const getExtendCallback = (certificate: Certificate) => async () => {
620 const renewedCertificate = await queryCertificate(
621 certificate.publicKey,
623 certificate.features || getFeatureValues(),
627 if (!renewedCertificate.DeviceName) {
628 renewedCertificate.DeviceName = nameInputRef?.current?.value || '';
631 const newCertificate = getCertificateModel(renewedCertificate, peer, certificate.privateKey);
632 setCertificates([...(certificates || []), newCertificate]);
633 setCurrentCertificate(newCertificate.id);
634 const formattedExpirationDate = readableTime(newCertificate.expirationTime, {
638 // translator: formattedExpirationDate is a date+time such as "Jan 31, 2023, 7:57 PM" with format appropriately localized to match current locale
639 text: c('Success notification').t`Certificate extended until ${formattedExpirationDate}`,
647 fetchUserVPN(30_000);
648 fetchLogicals(30_000);
652 <SettingsSectionWide>
654 {c('Info').t`These configurations are provided to work with WireGuard routers and official clients.`}
657 {logicalInfoLoading || certificatesLoading ? (
658 <div aria-busy="true" className="text-center mb-4">
663 {getCertificates().map((certificate) => {
664 const name = certificate.name || certificate.publicKeyFingerprint || certificate.publicKey;
665 const expirationDate = readableTime(certificate.expirationTime);
666 // translator: expirationDate is a date such as "Dec 11, 2022" (formatted according to current language)
667 const expirationString = c('Info').t`expires ${expirationDate}`;
668 const serverName = certificate?.features?.peerName || peer.name;
672 data-certificate-id={certificate.id}
674 open={certificate.id === currentCertificate}
675 onToggle={getToggleCallback(certificate)}
678 <Row className="justify-space-between" collapseOnMobile={false}>
679 <div className="text-ellipsis">{name}</div>
680 <div className="shrink-0">
684 {certificate.serialNumber ||
685 certificate.publicKeyFingerprint ||
686 certificate.publicKey ? (
688 certificate.publicKeyFingerprint ||
689 certificate.publicKey ||
696 className="label-stack-item-delete shrink-0"
697 onClick={askForRevocation(certificate)}
699 // translator: name is arbitrary name given by the user or a random key if not set
700 c('Action').t`Revoke ${name}`
706 className="label-stack-item-delete-icon"
707 alt={c('Action').t`Revoke`}
718 <Button loading={creating} onClick={getExtendCallback(certificate)}>{c('Action')
720 {certificate?.config && (
724 onClick={getDownloadCallback(certificate)}
725 >{c('Action').t`Download`}</Button>
727 <label className="block my-2">
728 {c('Label').t`Config to connect to ${serverName}`}
730 className="block mt-2"
731 value={certificate?.config}
741 <div aria-busy="true" className="text-center mt-4">
744 onClick={() => setLimit(limit + paginationSize)}
745 title={c('Action').t`Load more`}
746 >{c('Action').t`Load more`}</Button>
749 <form onSubmit={handleFormSubmit} className="mt-8">
750 <h3 className="mt-8 mb-2">{c('Title').t`1. Give a name to the config to be generated`}</h3>
752 id="certificate-device-name"
756 {c('Label').t`Device/certificate name`}
760 .t`A name to help you identify where you use it so you can easily revoke it or extend it later.`}
764 placeholder={c('Label').t`Choose a name for the generated certificate file`}
767 <h3 className="mt-8 mb-2">{c('Title').t`2. Select platform`}</h3>
768 <div className="flex flex-column md:flex-row">
771 value: PLATFORM.ANDROID,
772 label: c('Option').t`Android`,
776 label: c('Option').t`iOS`,
779 value: PLATFORM.WINDOWS,
780 label: c('Option').t`Windows`,
783 value: PLATFORM.MACOS,
784 label: c('Option').t`macOS`,
787 value: PLATFORM.LINUX,
788 label: c('Option').t`GNU/Linux`,
791 value: PLATFORM.ROUTER,
792 label: c('Option').t`Router`,
794 ].map(({ value, label }) => {
796 <div key={'wg-' + value} className="mr-8 mb-4">
798 id={'wg-platform-' + value}
799 onChange={() => setPlatform(value)}
800 checked={platform === value}
802 className="flex inline-flex *:self-center mb-2"
810 <h3 className="mt-8 mb-2">{c('Title').t`3. Select VPN options`}</h3>
811 {getFeatureKeys().map((key) => (
812 <div className="mb-4" key={'wg-feature-' + key}>
813 <label className="field-two-container w-full" htmlFor={'wg-feature-' + key}>
814 {isFeatureSelection(featuresConfig[key]) ? (
816 <div className="flex field-two-label-container justify-space-between flex-nowrap items-end">
817 <span className="field-two-label">
818 {featuresConfig[key].name}
819 {getFeatureLink(featuresConfig[key])}
823 id={'wg-feature-' + key}
824 key={'wg-feature-' + key}
825 value={featuresConfig[key].value}
826 onValue={(value) => setFeature(key, value)}
828 {(featuresConfig[key] as FeatureSelection).values.map((option) => (
830 key={'wg-feature-' + key + '-' + option.value}
840 key={'feature-' + key}
841 id={'feature-' + key}
842 checked={!!featuresConfig[key].value}
843 onChange={() => setFeature(key, !featuresConfig[key].value)}
846 {featuresConfig[key].name}
847 {getFeatureLink(featuresConfig[key])}
855 <h3 className="mt-8 mb-2">{c('Title').t`4. Select a server to connect to`}</h3>
857 <div className="mb-8">
862 .jt`Use the best server according to current load and position: ${formattedBestServerName}`}
864 <div className="mt-4">
865 <Button type="submit" color="norm" loading={creating}>{c('Action')
872 <p className="my-8">{c('Info').t`Or select a particular server:`}</p>
874 <OpenVPNConfigurationSection
875 onSelect={createWithLogical}
878 excludedCategories={[CATEGORY.COUNTRY]}
885 </SettingsSectionWide>
889 export default WireGuardConfigurationSection;