Init commit Eth-PM Wallet

This commit is contained in:
Franck Royer 2021-08-12 15:26:06 +10:00
parent 85fd5f8f9f
commit 42ace51f35
No known key found for this signature in database
GPG Key ID: A82ED75A8DFC50A4
31 changed files with 42206 additions and 0 deletions

View File

@ -0,0 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/cache/

View File

@ -0,0 +1,27 @@
# Ethereum Private Message Using Wallet Encryption Web App
**Demonstrates**:
- Private Messaging
- React/TypeScript
- Waku Light Push
- Signature with Web3
- Asymmetric Encryption
- Usage of [`eth_decrypt`](https://docs.metamask.io/guide/rpc-api.html#eth-decrypt) Wallet API
This dApp demonstrates how to send and received end-to-end encrypted messages
using the encryption API provided by some Web3 Wallet provider such as [Metamask](https://metamask.io/).
The sender only needs to know the Ethereum address of the recipient.
The recipient must broadcast his encryption public Key as a first step.
To run a development version locally, do:
```shell
git clone https://github.com/status-im/js-waku/ ; cd js-waku
npm install # Install dependencies for js-waku
npm run build # Build js-waku
cd examples/eth-pm-wallet-encryption
npm install # Install dependencies for the web app
npm run start # Start development server to serve the web app on http://localhost:3000/js-waku/eth-pm-wallet
```

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
{
"name": "eth-pm-wallet-encryption",
"version": "0.1.0",
"private": true,
"homepage": "/js-waku/eth-pm-wallet",
"dependencies": {
"@material-ui/core": "^4.11.4",
"@material-ui/icons": "^4.11.2",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "^26.0.15",
"@types/react": "^17.0.0",
"@types/react-dom": "^17.0.0",
"ethers": "^5.2.0",
"fontsource-roboto": "^4.0.0",
"js-waku": "../../build/main",
"protobufjs": "^6.11.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "run-s build:*",
"build:react": "react-scripts build",
"eject": "react-scripts eject",
"fix": "run-s fix:*",
"test": "run-s build test:*",
"test:lint": "eslint src --ext .ts --ext .tsx",
"test:prettier": "prettier \"src/**/*.{ts,tsx}\" \"./*.json\" --list-different",
"test:spelling": "cspell \"{README.md,src/**/*.{ts,tsx},public/**/*.html}\" -c ../../.cspell.json",
"fix:prettier": "prettier \"src/**/*.{ts,tsx}\" \"./*.json\" --write",
"fix:lint": "eslint src --ext .ts --ext .tsx --fix"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@ethersproject/shims": "^5.3.0",
"@types/node": "^14.17.3",
"cspell": "^5.6.6",
"eslint": "^7.29.0",
"npm-run-all": "^4.1.5",
"prettier": "^2.3.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #dddddd;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: black;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,221 @@
import '@ethersproject/shims';
import React, { useEffect, useState } from 'react';
import './App.css';
import { Waku } from 'js-waku';
import { Signer } from '@ethersproject/abstract-signer';
import { KeyPair } from './crypto';
import { Message } from './messaging/Messages';
import 'fontsource-roboto';
import { AppBar, IconButton, Toolbar, Typography } from '@material-ui/core';
import KeyPairHandling from './key_pair_handling/KeyPairHandling';
import {
createMuiTheme,
ThemeProvider,
makeStyles,
} from '@material-ui/core/styles';
import { teal, purple, green } from '@material-ui/core/colors';
import WifiIcon from '@material-ui/icons/Wifi';
import BroadcastPublicKey from './BroadcastPublicKey';
import Messaging from './messaging/Messaging';
import {
DirectMessageContentTopic,
handleDirectMessage,
handlePublicKeyMessage,
initWaku,
PublicKeyContentTopic,
} from './waku';
import ConnectWallet from './ConnectWallet';
const theme = createMuiTheme({
palette: {
primary: {
main: purple[500],
},
secondary: {
main: teal[600],
},
},
});
const useStyles = makeStyles({
root: {
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
},
appBar: {
// height: '200p',
},
container: {
display: 'flex',
flex: 1,
},
main: {
flex: 1,
margin: '10px',
},
wakuStatus: {
marginRight: theme.spacing(2),
},
title: {
flexGrow: 1,
},
peers: {},
});
function App() {
const [waku, setWaku] = useState<Waku>();
const [signer, setSigner] = useState<Signer>();
const [EncryptionKeyPair, setEncryptionKeyPair] = useState<
KeyPair | undefined
>();
const [publicKeys, setPublicKeys] = useState<Map<string, Uint8Array>>(
new Map()
);
const [messages, setMessages] = useState<Message[]>([]);
const [address, setAddress] = useState<string>();
const classes = useStyles();
// Waku initialization
useEffect(() => {
if (waku) return;
initWaku()
.then((_waku) => {
console.log('waku: ready');
setWaku(_waku);
})
.catch((e) => {
console.error('Failed to initiate Waku', e);
});
}, [waku]);
useEffect(() => {
if (!waku) return;
const observerPublicKeyMessage = handlePublicKeyMessage.bind(
{},
address,
setPublicKeys
);
waku.relay.addObserver(observerPublicKeyMessage, [PublicKeyContentTopic]);
return function cleanUp() {
if (!waku) return;
waku.relay.deleteObserver(observerPublicKeyMessage, [
PublicKeyContentTopic,
]);
};
}, [waku, address]);
useEffect(() => {
if (!waku) return;
if (!EncryptionKeyPair) return;
waku.relay.addDecryptionKey(EncryptionKeyPair.privateKey);
return function cleanUp() {
if (!waku) return;
if (!EncryptionKeyPair) return;
waku.relay.deleteDecryptionKey(EncryptionKeyPair.privateKey);
};
}, [waku, EncryptionKeyPair]);
useEffect(() => {
if (!waku) return;
if (!EncryptionKeyPair) return;
if (!address) return;
const observerDirectMessage = handleDirectMessage.bind(
{},
setMessages,
address
);
waku.relay.addObserver(observerDirectMessage, [DirectMessageContentTopic]);
return function cleanUp() {
if (!waku) return;
if (!observerDirectMessage) return;
waku.relay.deleteObserver(observerDirectMessage, [
DirectMessageContentTopic,
]);
};
}, [waku, address, EncryptionKeyPair]);
let relayPeers = 0;
let lightPushPeers = 0;
if (waku) {
relayPeers = waku.relay.getPeers().size;
lightPushPeers = waku.lightPush.peers.length;
}
let addressDisplay = '';
if (address) {
addressDisplay =
address.substr(0, 6) + '...' + address.substr(address.length - 4, 4);
}
return (
<ThemeProvider theme={theme}>
<div className={classes.root}>
<AppBar className={classes.appBar} position="static">
<Toolbar>
<IconButton
edge="start"
className={classes.wakuStatus}
aria-label="waku-status"
>
<WifiIcon
color={waku ? undefined : 'disabled'}
style={waku ? { color: green[500] } : {}}
/>
</IconButton>
<Typography className={classes.peers} aria-label="connected-peers">
Peers: {relayPeers} relay, {lightPushPeers} light push
</Typography>
<Typography variant="h6" className={classes.title}>
Ethereum Direct Message
</Typography>
<Typography>{addressDisplay}</Typography>
</Toolbar>
</AppBar>
<div className={classes.container}>
<main className={classes.main}>
<fieldset>
<legend>Wallet</legend>
<ConnectWallet setAddress={setAddress} setSigner={setSigner} />
</fieldset>
<fieldset>
<legend>Encryption Key Pair</legend>
<KeyPairHandling
encryptionKeyPair={EncryptionKeyPair}
setEncryptionKeyPair={setEncryptionKeyPair}
/>
<BroadcastPublicKey
signer={signer}
EncryptionKeyPair={EncryptionKeyPair}
waku={waku}
/>
</fieldset>
<fieldset>
<legend>Messaging</legend>
<Messaging
recipients={publicKeys}
waku={waku}
messages={messages}
/>
</fieldset>
</main>
</div>
</div>
</ThemeProvider>
);
}
export default App;

View File

@ -0,0 +1,79 @@
import { Button } from '@material-ui/core';
import React, { useState } from 'react';
import { createPublicKeyMessage, KeyPair } from './crypto';
import { PublicKeyMessage } from './messaging/wire';
import { WakuMessage, Waku } from 'js-waku';
import { Signer } from '@ethersproject/abstract-signer';
import { PublicKeyContentTopic } from './waku';
interface Props {
EncryptionKeyPair: KeyPair | undefined;
waku: Waku | undefined;
signer: Signer | undefined;
}
export default function BroadcastPublicKey({
signer,
EncryptionKeyPair,
waku,
}: Props) {
const [publicKeyMsg, setPublicKeyMsg] = useState<PublicKeyMessage>();
const broadcastPublicKey = () => {
if (!EncryptionKeyPair) return;
if (!signer) return;
if (!waku) return;
if (publicKeyMsg) {
encodePublicKeyWakuMessage(publicKeyMsg)
.then((wakuMsg) => {
waku.lightPush.push(wakuMsg).catch((e) => {
console.error('Failed to send Public Key Message', e);
});
})
.catch(() => {
console.log('Failed to encode Public Key Message in Waku Message');
});
} else {
createPublicKeyMessage(signer, EncryptionKeyPair.publicKey)
.then((msg) => {
setPublicKeyMsg(msg);
encodePublicKeyWakuMessage(msg)
.then((wakuMsg) => {
waku.lightPush
.push(wakuMsg)
.then((res) => console.log('Public Key Message pushed', res))
.catch((e) => {
console.error('Failed to send Public Key Message', e);
});
})
.catch(() => {
console.log(
'Failed to encode Public Key Message in Waku Message'
);
});
})
.catch((e) => {
console.error('Failed to create public key message', e);
});
}
};
return (
<Button
variant="contained"
color="primary"
onClick={broadcastPublicKey}
disabled={!EncryptionKeyPair || !waku}
>
Broadcast Encryption Public Key
</Button>
);
}
async function encodePublicKeyWakuMessage(
publicKeyMessage: PublicKeyMessage
): Promise<WakuMessage> {
const payload = publicKeyMessage.encode();
return await WakuMessage.fromBytes(payload, PublicKeyContentTopic);
}

View File

@ -0,0 +1,33 @@
import { Button } from '@material-ui/core';
import React from 'react';
import { Signer } from '@ethersproject/abstract-signer';
import { ethers } from 'ethers';
declare let window: any;
interface Props {
setAddress: (address: string) => void;
setSigner: (signer: Signer) => void;
}
export default function ConnectWallet({ setAddress, setSigner }: Props) {
const connectWallet = () => {
try {
window.ethereum
.request({ method: 'eth_requestAccounts' })
.then((accounts: string[]) => {
const _provider = new ethers.providers.Web3Provider(window.ethereum);
setAddress(accounts[0]);
setSigner(_provider.getSigner());
});
} catch (e) {
console.error('No web3 provider available');
}
};
return (
<Button variant="contained" color="primary" onClick={connectWallet}>
Connect Wallet
</Button>
);
}

View File

@ -0,0 +1,75 @@
import '@ethersproject/shims';
import { ethers } from 'ethers';
import { Signer } from '@ethersproject/abstract-signer';
import { PublicKeyMessage } from './messaging/wire';
import { hexToBuf, equalByteArrays, bufToHex } from 'js-waku/lib/utils';
import { generatePrivateKey, getPublicKey } from 'js-waku';
export interface KeyPair {
privateKey: Uint8Array;
publicKey: Uint8Array;
}
/**
* Use the signature of the Salt ("Salt for eth-dm...") as
* the entropy for the EthCrypto keypair. Note that the entropy is hashed with keccak256
* to make the private key.
*/
export async function generateEncryptionKeyPair(): Promise<KeyPair> {
const privateKey = generatePrivateKey();
const publicKey = getPublicKey(privateKey);
return { privateKey, publicKey };
}
/**
* Sign the Eth-DM public key with Web3. This can then be published to let other
* users know to use this Eth-DM public key to encrypt messages for the
* Ethereum Address holder.
*/
export async function createPublicKeyMessage(
web3Signer: Signer,
encryptionPublicKey: Uint8Array
): Promise<PublicKeyMessage> {
const ethAddress = await web3Signer.getAddress();
const signature = await web3Signer.signMessage(
formatPublicKeyForSignature(encryptionPublicKey)
);
return new PublicKeyMessage({
encryptionPublicKey: encryptionPublicKey,
ethAddress: hexToBuf(ethAddress),
signature: hexToBuf(signature),
});
}
/**
* Validate that the Encryption Public Key was signed by the holder of the given Ethereum address.
*/
export function validatePublicKeyMessage(msg: PublicKeyMessage): boolean {
const formattedMsg = formatPublicKeyForSignature(msg.encryptionPublicKey);
try {
const sigAddress = ethers.utils.verifyMessage(formattedMsg, msg.signature);
return equalByteArrays(sigAddress, msg.ethAddress);
} catch (e) {
console.log(
'Failed to verify signature for Public Key Message',
formattedMsg,
msg
);
return false;
}
}
/**
* Prepare Eth-Dm Public key to be signed for publication.
* The public key is set in on Object `{ encryptionPublicKey: string; }`, converted
* to JSON and then hashed with Keccak256.
* The usage of the object helps ensure the signature is only used in an Eth-DM
* context.
*/
function formatPublicKeyForSignature(encryptionPublicKey: Uint8Array): string {
return JSON.stringify({
encryptionPublicKey: bufToHex(encryptionPublicKey),
});
}

View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
);

View File

@ -0,0 +1,90 @@
import { Button } from '@material-ui/core';
import { LoadKeyPair } from './LoadKeyPair';
import { SaveKeyPair } from './SaveKeyPair';
import React, { useState } from 'react';
import { generateEncryptionKeyPair, KeyPair } from '../crypto';
import { makeStyles } from '@material-ui/core/styles';
import PasswordInput from './PasswordInput';
const useStyles = makeStyles({
root: {
textAlign: 'center',
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
margin: '5px',
},
generate: { margin: '5px' },
storage: {
margin: '5px',
},
loadSave: {
display: 'flex',
flexDirection: 'row',
margin: '5px',
},
loadSaveButton: {
margin: '5px',
},
});
export interface Props {
encryptionKeyPair: KeyPair | undefined;
setEncryptionKeyPair: (keyPair: KeyPair) => void;
}
export default function KeyPairHandling({
encryptionKeyPair,
setEncryptionKeyPair,
}: Props) {
const classes = useStyles();
const [password, setPassword] = useState<string>();
const generateKeyPair = () => {
if (encryptionKeyPair) return;
generateEncryptionKeyPair()
.then((keyPair) => {
setEncryptionKeyPair(keyPair);
})
.catch((e) => {
console.error('Failed to generate Key Pair', e);
});
};
return (
<div className={classes.root}>
<Button
className={classes.generate}
variant="contained"
color="primary"
onClick={generateKeyPair}
disabled={!!encryptionKeyPair}
>
Generate Encryption Key Pair
</Button>
<div className={classes.storage}>
<PasswordInput
password={password}
setPassword={(p) => setPassword(p)}
/>
<div className={classes.loadSave}>
<div className={classes.loadSaveButton}>
<LoadKeyPair
setEncryptionKeyPair={(keyPair) => setEncryptionKeyPair(keyPair)}
disabled={!!encryptionKeyPair}
password={password}
/>
</div>
<div className={classes.loadSaveButton}>
<SaveKeyPair
EncryptionKeyPair={encryptionKeyPair}
password={password}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { Button } from '@material-ui/core';
import React from 'react';
import { loadKeyPairFromStorage } from './key_pair_storage';
import { KeyPair } from '../crypto';
export interface Props {
setEncryptionKeyPair: (keyPair: KeyPair) => void;
disabled: boolean;
password: string | undefined;
}
export function LoadKeyPair({
password,
disabled,
setEncryptionKeyPair,
}: Props) {
const loadKeyPair = () => {
if (disabled) return;
if (!password) return;
loadKeyPairFromStorage(password).then((keyPair: KeyPair | undefined) => {
if (!keyPair) return;
console.log('Encryption KeyPair loaded from storage');
setEncryptionKeyPair(keyPair);
});
};
return (
<Button
variant="contained"
color="primary"
onClick={loadKeyPair}
disabled={!password || disabled}
>
Load Encryption Key Pair from storage
</Button>
);
}

View File

@ -0,0 +1,24 @@
import { TextField } from '@material-ui/core';
import React, { ChangeEvent } from 'react';
interface Props {
password: string | undefined;
setPassword: (password: string) => void;
}
export default function PasswordInput({ password, setPassword }: Props) {
const handlePasswordChange = (event: ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
};
return (
<TextField
id="password-input"
label="Password"
variant="filled"
type="password"
onChange={handlePasswordChange}
value={password}
/>
);
}

View File

@ -0,0 +1,30 @@
import { Button } from '@material-ui/core';
import React from 'react';
import { KeyPair } from '../crypto';
import { saveKeyPairToStorage } from './key_pair_storage';
export interface Props {
EncryptionKeyPair: KeyPair | undefined;
password: string | undefined;
}
export function SaveKeyPair({ password, EncryptionKeyPair }: Props) {
const saveKeyPair = () => {
if (!EncryptionKeyPair) return;
if (!password) return;
saveKeyPairToStorage(EncryptionKeyPair, password).then(() => {
console.log('Encryption KeyPair saved to storage');
});
};
return (
<Button
variant="contained"
color="primary"
onClick={saveKeyPair}
disabled={!password || !EncryptionKeyPair}
>
Save Encryption Key Pair to storage
</Button>
);
}

View File

@ -0,0 +1,122 @@
import { KeyPair } from '../crypto';
import { bufToHex, hexToBuf } from 'js-waku/lib/utils';
/**
* Save keypair to storage, encrypted with password
*/
export async function saveKeyPairToStorage(
EncryptionKeyPair: KeyPair,
password: string
) {
const { salt, iv, cipher } = await encryptKey(EncryptionKeyPair, password);
const data = {
salt: bufToHex(salt),
iv: bufToHex(iv),
cipher: bufToHex(cipher),
};
localStorage.setItem('cipherEncryptionKeyPair', JSON.stringify(data));
}
/**
* Load keypair from storage, decrypted using password
*/
export async function loadKeyPairFromStorage(
password: string
): Promise<KeyPair | undefined> {
const str = localStorage.getItem('cipherEncryptionKeyPair');
if (!str) return;
const data = JSON.parse(str);
const salt = hexToBuf(data.salt);
const iv = hexToBuf(data.iv);
const cipher = hexToBuf(data.cipher);
return await decryptKey(salt, iv, cipher, password);
}
/**
* Use password user as key material for wrap key.
*/
function getKeyMaterial(password: string): Promise<CryptoKey> {
const enc = new TextEncoder();
return window.crypto.subtle.importKey(
'raw',
enc.encode(password),
{ name: 'PBKDF2' },
false,
['deriveBits', 'deriveKey']
);
}
/**
* get key to store password
*/
function getWrapKey(keyMaterial: CryptoKey, salt: Uint8Array) {
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
}
/**
* Encrypt Eth-DM KeyPair using provided password
*/
async function encryptKey(encryptionKeyPair: KeyPair, password: string) {
const keyMaterial = await getKeyMaterial(password);
const salt = window.crypto.getRandomValues(new Uint8Array(16));
const wrappingKey = await getWrapKey(keyMaterial, salt);
const enc = new TextEncoder();
const encodedKeyPair = enc.encode(JSON.stringify(encryptionKeyPair));
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const cipher = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: iv,
},
wrappingKey,
encodedKeyPair
);
return { salt, iv, cipher };
}
/**
* Derive a key from a password, and use the key to decrypt the cipher key pair.
*/
async function decryptKey(
salt: Buffer,
iv: Buffer,
cipherKeyPair: Buffer,
password: string
): Promise<KeyPair | undefined> {
const keyMaterial = await getKeyMaterial(password);
const key = await getWrapKey(keyMaterial, salt);
try {
let decrypted = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: iv,
},
key,
cipherKeyPair
);
let dec = new TextDecoder();
return JSON.parse(dec.decode(decrypted));
} catch (e) {
return;
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,40 @@
import React from 'react';
import { List, ListItem, ListItemText } from '@material-ui/core';
/**
* Clear text message
*/
export interface Message {
text: string;
timestamp: Date;
}
export interface Props {
messages: Message[];
}
export default function Messages({ messages }: Props) {
return <List dense={true}>{generate(messages)}</List>;
}
function generate(messages: Message[]) {
return messages.map((msg) => {
const text = `<${formatDisplayDate(msg.timestamp)}> ${msg.text}`;
return (
<ListItem>
<ListItemText key={formatDisplayDate(msg.timestamp)} primary={text} />
</ListItem>
);
});
}
function formatDisplayDate(timestamp: Date): string {
return timestamp.toLocaleString([], {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: false,
});
}

View File

@ -0,0 +1,30 @@
import Messages, { Message } from './Messages';
import { Waku } from 'js-waku';
import SendMessage from './SendMessage';
import { makeStyles } from '@material-ui/core';
const useStyles = makeStyles({
root: {
display: 'flex',
alignItems: 'left',
flexDirection: 'column',
margin: '5px',
},
});
interface Props {
waku: Waku | undefined;
recipients: Map<string, Uint8Array>;
messages: Message[];
}
export default function Messaging({ waku, recipients, messages }: Props) {
const classes = useStyles();
return (
<div className={classes.root}>
<SendMessage recipients={recipients} waku={waku} />
<Messages messages={messages} />
</div>
);
}

View File

@ -0,0 +1,148 @@
import {
FormControl,
InputLabel,
makeStyles,
MenuItem,
Select,
TextField,
} from '@material-ui/core';
import React, { ChangeEvent, useState, KeyboardEvent } from 'react';
import { Waku, WakuMessage } from 'js-waku';
import { hexToBuf } from 'js-waku/lib/utils';
import { DirectMessage } from './wire';
import { DirectMessageContentTopic } from '../waku';
const useStyles = makeStyles((theme) => ({
formControl: {
margin: theme.spacing(1),
minWidth: 120,
},
selectEmpty: {
marginTop: theme.spacing(2),
},
}));
export interface Props {
waku: Waku | undefined;
// address, public key
recipients: Map<string, Uint8Array>;
}
export default function SendMessage({ waku, recipients }: Props) {
const classes = useStyles();
const [recipient, setRecipient] = useState<string>('');
const [message, setMessage] = useState<string>();
const handleRecipientChange = (
event: ChangeEvent<{ name?: string; value: unknown }>
) => {
setRecipient(event.target.value as string);
};
const handleMessageChange = (event: ChangeEvent<HTMLInputElement>) => {
setMessage(event.target.value);
};
const items = Array.from(recipients.keys()).map((recipient) => {
return (
<MenuItem key={recipient} value={recipient}>
{recipient}
</MenuItem>
);
});
const keyDownHandler = async (event: KeyboardEvent<HTMLInputElement>) => {
if (
event.key === 'Enter' &&
!event.altKey &&
!event.ctrlKey &&
!event.shiftKey
) {
if (!waku) return;
if (!recipient) return;
if (!message) return;
const publicKey = recipients.get(recipient);
if (!publicKey) return;
sendMessage(waku, recipient, publicKey, message, (res) => {
if (res) {
console.log('callback called with', res);
setMessage('');
}
});
}
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<FormControl className={classes.formControl}>
<InputLabel id="select-recipient-label">Recipient</InputLabel>
<Select
labelId="select-recipient"
id="select-recipient"
value={recipient}
onChange={handleRecipientChange}
>
{items}
</Select>
</FormControl>
<TextField
id="message-input"
label="Message"
variant="filled"
onChange={handleMessageChange}
onKeyDown={keyDownHandler}
value={message}
/>
</div>
);
}
async function encodeEncryptedWakuMessage(
message: string,
publicKey: Uint8Array,
address: string
): Promise<WakuMessage> {
const directMsg = new DirectMessage({
toAddress: hexToBuf(address),
message: message,
});
const payload = directMsg.encode();
return WakuMessage.fromBytes(payload, DirectMessageContentTopic, {
encPublicKey: publicKey,
});
}
function sendMessage(
waku: Waku,
recipientAddress: string,
recipientPublicKey: Uint8Array,
message: string,
callback: (res: boolean) => void
) {
encodeEncryptedWakuMessage(message, recipientPublicKey, recipientAddress)
.then((msg) => {
console.log('pushing');
waku.lightPush
.push(msg)
.then((res) => {
console.log('Message sent', res);
callback(res ? res.isSuccess : false);
})
.catch((e) => {
console.error('Failed to send message', e);
callback(false);
});
})
.catch((e) => {
console.error('Cannot encode & encrypt message', e);
callback(false);
});
}

View File

@ -0,0 +1,101 @@
import * as protobuf from 'protobufjs/light';
export interface PublicKeyMessagePayload {
encryptionPublicKey: Uint8Array;
ethAddress: Uint8Array;
signature: Uint8Array;
}
const Root = protobuf.Root,
Type = protobuf.Type,
Field = protobuf.Field;
/**
* Message used to communicate the Eth-Dm public key linked to a given Ethereum account
*/
export class PublicKeyMessage {
private static Type = new Type('PublicKeyMessage')
.add(new Field('encryptionPublicKey', 1, 'bytes'))
.add(new Field('ethAddress', 2, 'bytes'))
.add(new Field('signature', 3, 'bytes'));
private static Root = new Root()
.define('messages')
.add(PublicKeyMessage.Type);
constructor(public payload: PublicKeyMessagePayload) {}
public encode(): Uint8Array {
const message = PublicKeyMessage.Type.create(this.payload);
return PublicKeyMessage.Type.encode(message).finish();
}
public static decode(
bytes: Uint8Array | Buffer
): PublicKeyMessage | undefined {
const payload = PublicKeyMessage.Type.decode(
bytes
) as unknown as PublicKeyMessagePayload;
if (
!payload.signature ||
!payload.encryptionPublicKey ||
!payload.ethAddress
) {
console.log('Field missing on decoded Public Key Message', payload);
return;
}
return new PublicKeyMessage(payload);
}
get encryptionPublicKey(): Uint8Array {
return this.payload.encryptionPublicKey;
}
get ethAddress(): Uint8Array {
return this.payload.ethAddress;
}
get signature(): Uint8Array {
return this.payload.signature;
}
}
export interface DirectMessagePayload {
toAddress: Uint8Array;
message: string;
}
/**
* Direct Encrypted Message used for private communication over the Waku network.
*/
export class DirectMessage {
private static Type = new Type('DirectMessage')
.add(new Field('toAddress', 1, 'bytes'))
.add(new Field('message', 2, 'string'));
private static Root = new Root().define('messages').add(DirectMessage.Type);
constructor(public payload: DirectMessagePayload) {}
public encode(): Uint8Array {
const message = DirectMessage.Type.create(this.payload);
return DirectMessage.Type.encode(message).finish();
}
public static decode(bytes: Uint8Array | Buffer): DirectMessage | undefined {
const payload = DirectMessage.Type.decode(
bytes
) as unknown as DirectMessagePayload;
if (!payload.toAddress || !payload.message) {
console.log('Field missing on decoded Direct Message', payload);
return;
}
return new DirectMessage(payload);
}
get toAddress(): Uint8Array {
return this.payload.toAddress;
}
get message(): string {
return this.payload.message;
}
}

View File

@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -0,0 +1,85 @@
import { Dispatch, SetStateAction } from 'react';
import { getStatusFleetNodes, Waku, WakuMessage } from 'js-waku';
import { DirectMessage, PublicKeyMessage } from './messaging/wire';
import { validatePublicKeyMessage } from './crypto';
import { Message } from './messaging/Messages';
import { bufToHex, equalByteArrays } from 'js-waku/lib/utils';
export const PublicKeyContentTopic = '/eth-dm/1/public-key/proto';
export const DirectMessageContentTopic = '/eth-dm/1/direct-message/proto';
export async function initWaku(): Promise<Waku> {
const waku = await Waku.create({});
// Dial all nodes it can find
getStatusFleetNodes().then((nodes) => {
nodes.forEach((addr) => {
waku.dial(addr);
});
});
// Wait to be connected to at least one peer
await new Promise((resolve, reject) => {
// If we are not connected to any peer within 10sec let's just reject
// As we are not implementing connection management in this example
setTimeout(reject, 10000);
waku.libp2p.connectionManager.on('peer:connect', () => {
resolve(null);
});
});
return waku;
}
export function handlePublicKeyMessage(
myAddress: string | undefined,
setter: Dispatch<SetStateAction<Map<string, Uint8Array>>>,
msg: WakuMessage
) {
console.log('Public Key Message received:', msg);
if (!msg.payload) return;
const publicKeyMsg = PublicKeyMessage.decode(msg.payload);
if (!publicKeyMsg) return;
if (myAddress && equalByteArrays(publicKeyMsg.ethAddress, myAddress)) return;
const res = validatePublicKeyMessage(publicKeyMsg);
console.log('Is Public Key Message valid?', res);
if (res) {
setter((prevPks: Map<string, Uint8Array>) => {
prevPks.set(
bufToHex(publicKeyMsg.ethAddress),
publicKeyMsg.encryptionPublicKey
);
return new Map(prevPks);
});
}
}
export async function handleDirectMessage(
setter: Dispatch<SetStateAction<Message[]>>,
address: string,
wakuMsg: WakuMessage
) {
console.log('Direct Message received:', wakuMsg);
if (!wakuMsg.payload) return;
const directMessage = DirectMessage.decode(wakuMsg.payload);
if (!directMessage) {
console.log('Failed to decode Direct Message');
return;
}
if (!equalByteArrays(directMessage.toAddress, address)) return;
const timestamp = wakuMsg.timestamp ? wakuMsg.timestamp : new Date();
console.log('Message decrypted:', directMessage.message);
setter((prevMsgs: Message[]) => {
const copy = prevMsgs.slice();
copy.push({
text: directMessage.message,
timestamp: timestamp,
});
return copy;
});
}

View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}