Private Valentines demo (#9)

JS Client changes:
Gracefully return null if key is not found in bucket (instead of throwing)
Support single and multi-key lookups in a unified privateRead function
This commit is contained in:
Neil Movva
2023-02-15 00:53:54 -08:00
committed by GitHub
parent 9ae00d8272
commit b2968962e7
13 changed files with 819 additions and 8 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ node_modules
.env/
*.pyc
*build/
.DS_Store

Binary file not shown.

View File

@@ -0,0 +1,36 @@
{
"name": "blyss-private-valentines",
"version": "1.0.0",
"description": "",
"type": "module",
"keywords": [],
"main": "src/index.tsx",
"dependencies": {
"react": "18.0.0",
"react-dom": "18.0.0",
"react-scripts": "5.0.1"
},
"devDependencies": {
"@types/react": "18.0.25",
"@types/react-dom": "18.0.9",
"typescript": "4.4.2"
},
"scripts": {
"start": "PORT=3010 BROWSER=none react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test --env=jsdom",
"eject": "react-scripts eject"
},
"browserslist": {
"production": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<title>Private Valentine Retrieval</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,5 @@
{
"infiniteLoopProtection": false,
"hardReloadOnChange": false,
"view": "browser"
}

View File

@@ -0,0 +1,432 @@
import './styles.css';
import { Log, LogMessage } from './util';
import { Bucket, Client } from '@blyss/sdk';
import React, { useState } from 'react';
// This function gets called only on the first query
async function setup(apiKey: string): Promise<Bucket> {
const client = new Client(apiKey);
// Create the bucket, if it doesn't exist.
// By default, only you can read and write from the buckets you create.
// To make a bucket others can read, prefix the name with "global."
const bucketName = 'global.private-valentines-13643';
if (!(await client.exists(bucketName))) {
console.log('creating bucket');
await client.create(bucketName);
}
// Connect to your bucket
const bucket = await client.connect(bucketName);
return bucket;
}
async function deriveMessageKey(recipient: string): Promise<CryptoKey> {
// 0.1. Create base key material from recipient handle
const baseKey = await window.crypto.subtle.importKey(
'raw',
new TextEncoder().encode(recipient),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
const pkbdf2Params = {
name: 'PBKDF2',
salt: new TextEncoder().encode('valentine'),
iterations: 100000,
hash: 'SHA-256'
};
const aesGenKeyParams = {
name: 'AES-GCM',
length: 256
};
const key = await window.crypto.subtle.deriveKey(
pkbdf2Params,
baseKey,
aesGenKeyParams,
false,
['encrypt', 'decrypt']
);
return key;
}
async function computeServerKey(mailbox: string): Promise<string> {
// 2.2. Hash recipient's handle to get destination key on server
const hash = await window.crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(mailbox)
);
// 2.3. Convert hash to base64 string
const targetKey = window.btoa(String.fromCharCode(...new Uint8Array(hash)));
return targetKey;
}
function SendValentineCard({
loading,
handler
}: {
loading: boolean;
handler: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
<form className="actioncard" onSubmit={handler}>
<h2>😘 Send</h2>
<div className="actioncard-field">
<h3>To:</h3>
<input
type="text"
id="to"
// placeholder="mailbox destination"
title="up to 500 Unicode chars, enforced by truncation."
required
/>
</div>
<div className="actioncard-field">
<h3>Valentine message:</h3>
<textarea
id="msg"
// placeholder="Valentine message"
title="UTF8 up to 1KiB, enforced by truncation. Message will be client-side encrypted, using a key derived from the recipient's name."
required
/>
</div>
<div className="actioncard-buttons">
<button disabled={loading}>
{loading ? ' sending... ' : 'send valentine'}
</button>
<div>{loading ? <div className="loader"></div> : null}</div>
</div>
</form>
);
}
function PrivateReceiveValentineCard({
loading,
fetchedMessage,
handler
}: {
loading: boolean;
fetchedMessage: string;
handler: (e: React.FormEvent<HTMLFormElement>) => void;
}) {
return (
<form className="actioncard" onSubmit={handler}>
<h2>💌 Private Retrieve</h2>
<div className="actioncard-field">
<h3>Mailbox to check:</h3>
<input
type="text"
id="to"
placeholder="(recipient's name)"
title="up to 500 Unicode chars, enforced by truncation."
required
/>
</div>
<div className="actioncard-field">
<h3>Received message:</h3>
<textarea
className="fetchedMessage"
id="msg"
value={fetchedMessage}
title="display for the fetched message."
readOnly
/>
</div>
<div className="actioncard-buttons">
<button disabled={loading}>
{loading ? 'fetching...' : 'fetch valentine'}
</button>
<div>{loading ? <div className="loader"></div> : null}</div>
</div>
</form>
);
}
function Explainer() {
return (
<div className="explainer">
<p>
Send and receive encrypted valentines, while keeping your amours
identity fully private. Your browser will fetch Valentines via the{' '}
<a href="https://blyss.dev">Blyss</a> protocol, which secures{' '}
<i>metadata</i> - like which Valentines are you looking at?" More info{' '}
<a href="#faq">below</a>.
</p>
</div>
);
}
function Faq() {
return (
<div className="FAQ" id="faq">
<h2>FAQ</h2>
<h4>
Is this really homomorphic encryption? I thought that was impossible /
really slow.
</h4>
<p>
Yup, this is real-deal fully homomorphic encryption, running as realtime
multiplayer in your browser. Five years ago, this demo probably did seem
impossible, but a lot of recent work has made FHE fast enough for some
specific applications, like the private information retrieval we show
here. Want to try using fast FHE in your own apps? Here's our{' '}
<a href="https://github.com/blyssprivacy/sdk">open-source SDK!</a>
</p>
<h4>How are my message contents secured?</h4>
<p>
To <b>send</b> a message addressed to mailbox <strong>M</strong>, the
browser client first derives a key{' '}
<code>
<strong>K</strong> = PKBDF2(
<strong>M</strong>)
</code>
, using a fixed salt. <strong>K</strong> is used by the client to AES
encrypt the message; <strong>K</strong> never leaves your device. Of
course, the server can't know <strong>M</strong>, so the client writes
the encrypted message to server location{' '}
<code>
<strong>L</strong> = SHA256(<strong>M</strong>)
</code>
.<br></br>
<br></br>
To <b>retrieve</b> a message sent to mailbox <strong>M</strong>, we do
the same steps in reverse: the client first computes{' '}
<code>
<strong>L</strong> = SHA256(<strong>M</strong>)
</code>
, then performs a metadata-private read for <strong>L</strong>; the
result is the encrypted message data of mailbox <strong>M</strong>,
which the client finally decrypts with key{' '}
<code>
<strong>K</strong> = PKBDF2(<strong>M</strong>)
</code>
.
</p>
<h4>How is my message metadata secured?</h4>
<p>
Fully homomorphic encryption (FHE) is what makes this special. It lets
the server retrieve any data the client requests, while the server
remains completely oblivious to the client's selection. Here are some
more detailed explainers on FHE, in increasing levels of technicality: a{' '}
<a href="https://blintzbase.com/posts/pir-and-fhe-from-scratch/">
blog post
</a>{' '}
we wrote, our{' '}
<a href="https://github.com/blyssprivacy/sdk">source code</a>, and a{' '}
<a href="https://eprint.iacr.org/2022/368">paper we published</a>.
</p>
<h4>Is this like end-to-end encryption?</h4>
<p>
No, this is a different kind of privacy. In this toy demo, your message
contents are encrypted, but under a weak key that is merely derived from
the recipient's name - not something we'd ever call E2E. But the
metadata of message retrievals is actually protected, so the server
cannot know whom is messaging whom. Caveat: regardless of encryption
strategy, patterns in client activity can always hint at client
relationships, unless communicating parties take care to decorrelate
their actions.
</p>
<h4>Could this be used as a metadata-private messenger?</h4>
<p>
With a couple more steps (starting with E2EE), maybe! If you're
interested in this sort of thing, we should{' '}
<a href="mailto:founders@blyss.dev">definitely talk</a>.
</p>
</div>
);
}
// UI
function App() {
const [bucketHandle, setBucketHandle] = useState<Bucket | undefined>();
const [loading, setLoading] = useState(false);
const [posting, setPosting] = useState(false);
const [apiKey, setApiKey] = useState(
'CSdK3rKXvb4zb43AgycQn6KAmJDILMXU8IWUHrn7'
);
const [numMessages, setNumMessages] = useState(
Math.floor(Math.random() * 100) + 950
);
const [fetchedMessage, setfetchedMessage] = useState('');
const [trace, setTrace] = useState<Log[]>([]);
const logMessage = (t: Log) => setTrace([t, ...trace]);
async function animatePost(to: string, message: string): Promise<void> {
setPosting(true);
// enforce size limits
if (to.length > 500) {
to = to.slice(0, 500);
}
if (message.length > 1000) {
message = message.slice(0, 500);
}
// 0. Derive an encryption key from the recipient's handle
const key = await deriveMessageKey(to);
// 1. Get a handle to the bucket
let bucket = bucketHandle;
if (!bucket) {
console.log('setup!');
bucket = await setup(apiKey);
setBucketHandle(bucket);
}
// 2.1. Encrypt the message
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encryptedMessage = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv
},
key,
new TextEncoder().encode(message)
);
// 2.2. Prepend iv to encrypted message
const encryptedMessageWithIv = new Uint8Array([
...iv,
...new Uint8Array(encryptedMessage)
]);
// 2.3. Write encrypted message to KV server
const serverKey = await computeServerKey(to);
const start = performance.now();
const _ = await bucket.write({
[serverKey]: encryptedMessageWithIv
});
const tookMs = performance.now() - start;
const isRetrieval = false;
// 3. Log the result to the UI
logMessage({
to,
isRetrieval,
tookMs
});
setPosting(false);
}
async function animateFetch(to: string): Promise<void> {
setLoading(true);
// enforce size limits
if (to.length > 500) {
to = to.slice(0, 500);
}
// 1. Get a handle to the bucket
let bucket = bucketHandle;
if (!bucket) {
console.log('setup!');
bucket = await setup(apiKey);
setBucketHandle(bucket);
}
// 2. Retrieve the specified mailbox
const serverKey = await computeServerKey(to);
const start = performance.now();
const fetchedResult = await bucket.privateRead(serverKey);
const tookMs = performance.now() - start;
const isRetrieval = true;
// 3. Log the result to the UI
logMessage({
to,
isRetrieval,
tookMs
});
if (fetchedResult === null) {
// 4.1 If the mailbox is empty, we're done
console.log('no messages yet :(');
setfetchedMessage('no messages yet :(');
} else {
// 4.2 Decrypt the message
console.log('decrypting message...');
const key = await deriveMessageKey(to);
const decryptedMessage = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: fetchedResult.slice(0, 12)
},
key,
fetchedResult.slice(12)
);
const decodedMessage = new TextDecoder().decode(decryptedMessage);
setfetchedMessage(decodedMessage);
}
setLoading(false);
}
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
const to = (event.target as any).to.value;
const msg = (event.target as any).msg.value;
console.log('Sending message ' + msg + ' to mailbox ' + to);
return animatePost(to, msg);
};
const handleFetch = (event: React.FormEvent) => {
event.preventDefault();
const to = (event.target as any).to.value;
console.log('Checking mailbox: ' + to + '...');
return animateFetch(to);
};
return (
<div className="App">
<div className="App-main">
<div className="title">
<h2 style={{ margin: 0 }}>Private Valentine Retrieval</h2>
<h3 style={{ margin: 0, color: '#F68E9D' }}>
(using homomorphic encryption!)
</h3>
</div>
<Explainer></Explainer>
<div className="deck">
<PrivateReceiveValentineCard
loading={loading}
fetchedMessage={fetchedMessage}
handler={handleFetch}
></PrivateReceiveValentineCard>
<SendValentineCard
loading={posting}
handler={handleSubmit}
></SendValentineCard>
</div>
</div>
<div className="footer">
<Faq></Faq>
<div className="trace">
<div>
<h2>Trace</h2>
</div>
<div>
{trace.length > 0
? trace.map((t, i) => (
<div key={i}>
<LogMessage {...t} />
</div>
))
: null}
</div>
</div>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
const rootElement = document.getElementById("root");
const root = createRoot(rootElement!);
root.render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,218 @@
@font-face {
font-family: 'PlexMonoRegular';
src: url('../fonts/IBMPlexMono-Regular.woff2') format('woff2');
}
:root {
--blyss-pink: #F68E9D;
}
body {
margin: auto;
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;
background-color: #282c34;
color: white;
}
a {
color: var(--blyss-pink);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
background-color: #222;
border-radius: 5px;
padding: 5px;
}
.App {
display: flex;
flex-direction: column;
/* align-items: center; */
max-width: 900px;
margin: auto;
padding: 20px;
}
.title {
font-family: "PlexMonoRegular";
text-align: center;
font-size: 30px;
margin-top: 5%;
}
.deck {
display: flex;
flex-direction: row;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.actioncard {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 10px;
padding: 20px;
border: var(--blyss-pink) dashed 2px;
border-radius: 20px;
width: 300px;
height: 400px;
background-color: #222;
font-family: "PlexMonoRegular";
font-size: 16px;
}
.actioncard-field {
display: flex;
flex-direction: column;
align-items: stretch;
color: black;
}
.actioncard-field h3 {
color: white;
margin-top: 10px;
margin-bottom: 10px;
}
.actioncard input, .actioncard textarea {
background: #FFF;
border: none;
border-radius: 5px;
padding: 10px;
/* without this repeat, input won't inherit from actioncard? */
font-family: "PlexMonoRegular";
font-size: 16px;
resize: none; /* hides the resize handle in lower-right corner */
}
.actioncard textarea {
flex-grow: 2;
height: 100px;
}
.actioncard-field .fetchedMessage {
color: white;
background-color: #282c34;
}
::placeholder {
color: #555;
}
.actioncard h2 {
margin: 0px;
}
.actioncard-buttons {
display: flex;
flex-direction: row;
column-gap: 20px;
align-items: center;
margin-top: 10px;
}
button {
background: var(--blyss-pink);
border: none;
border-radius: 10px;
padding: 10px;
font-family: "PlexMonoRegular";
font-size: 16px;
color: black;
text-align: center;
}
.App-main {
/* padding: 60px;
padding-top: 120px; */
display: flex;
flex-direction: column;
gap: 20px;
}
.explainer {
font-size: 24px;
line-height: 1.5;
margin-left: 10%;
margin-right: 10%;
}
.footer {
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
margin-top: 50px;
gap: 50px;
}
.FAQ {
max-width: 500px;
line-height: 1.6;
}
.FAQ h4{
margin-bottom: 0px
}
.FAQ p{
margin-top: 10px
}
.trace {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 200px;
}
.logline {
margin-bottom: 10px
}
@keyframes heartBeat {
0% { transform: scale(0.95) }
5% { transform: scale(1.1) }
39% { transform: scale(0.85) }
45% { transform: scale(1) }
60% { transform: scale(0.95) }
100% { transform: scale(0.9) }
}
.loader {
position: relative;
width: 20px;
height: 30px;
animation: heartBeat 1.2s infinite cubic-bezier(0.215, 0.61, 0.355, 1);
}
.loader:before,
.loader:after {
content: "";
background: #ff3d00 ;
width: 20px;
height: 30px;
border-radius: 50px 50px 0 0;
position: absolute;
left: 0;
bottom: 0;
transform: rotate(45deg);
transform-origin: 50% 68%;
box-shadow: 5px 4px 5px #0004 inset;
}
.loader:after {
transform: rotate(-45deg);
}

View File

@@ -0,0 +1,28 @@
export interface Log {
to: string;
isRetrieval: boolean;
tookMs: number;
}
export function LogMessage({ to, isRetrieval, tookMs }: Log) {
const tookMsg = (
<span style={{ color: '#666', paddingLeft: 5 }}>
({Math.round((tookMs / 1000) * 10) / 10} s)
</span>
);
return (
<>
<div className="logline">
{!isRetrieval ? (
<>
Sent a valentine to {to}. {tookMsg}
</>
) : (
<>
Privately checked mailbox for {to}. {tookMsg}.
</>
)}
</div>
</>
);
}

View File

@@ -0,0 +1,26 @@
{
"include": [
"./src/**/*"
],
"compilerOptions": {
"target": "es6",
"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"
}
}

View File

@@ -87,8 +87,25 @@ export class Bucket {
private async getEndResult(key: string, queryResult: Uint8Array) {
const decryptedResult = this.lib.decodeResponse(queryResult);
const decompressedResult = decompress(decryptedResult);
const extractedResult = this.lib.extractResult(key, decompressedResult);
let decompressedResult = null;
try {
decompressedResult = decompress(decryptedResult);
} catch (e) {
console.log('decompress error', e);
}
if (decompressedResult === null) {
return null;
}
let extractedResult = null;
try {
extractedResult = this.lib.extractResult(key, decompressedResult);
} catch {}
if (extractedResult === null) {
return null;
}
const result = deserialize(extractedResult);
return result;
}
@@ -259,8 +276,14 @@ export class Bucket {
*
* @param {string} key - The key to _privately_ retrieve the value of.
*/
async privateRead(key: string): Promise<any> {
return (await this.performPrivateRead(key)).data;
async privateRead(key: string | string[]): Promise<any> {
if (Array.isArray(key)) {
return (await this.performPrivateReads(key)).map(r => r.data);
} else {
console.log('key', key);
let result = await this.performPrivateRead(key);
return result ? result.data : null;
}
}
/**
@@ -289,19 +312,25 @@ export class Bucket {
*
* @param keys - The keys to _privately_ intersect the value of.
*/
async privateIntersect(keys: string[]): Promise<any> {
async privateIntersect(
keys: string[],
retrieveValues: boolean = true
): Promise<any> {
if (keys.length < BLOOM_CUTOFF) {
return (await this.performPrivateReads(keys)).map(x => x.data);
}
const bloomFilter = await this.api.bloom(this.name);
const matches = [];
const matches: string[] = [];
for (const key of keys) {
if (await bloomLookup(bloomFilter, key)) {
matches.push(key);
}
}
if (!retrieveValues) {
return matches;
}
return (await this.performPrivateReads(matches)).map(x => x.data);
}

View File

@@ -5,12 +5,15 @@ import { Bucket } from './bucket';
export interface BucketParameters {
/** The maximum item size this bucket supports */
maxItemSize: MaxItemSizeIdentifier;
keyStoragePolicy: kspIdentifier;
}
type MaxItemSizeIdentifier = '100B' | '1KB' | '10KB';
type kspIdentifier = 'none' | 'bloom' | 'full';
const DEFAULT_BUCKET_PARAMETERS: BucketParameters = {
maxItemSize: '1KB'
maxItemSize: '1KB',
keyStoragePolicy: 'bloom'
};
const BLYSS_BUCKET_URL = 'https://beta.api.blyss.dev';

View File

@@ -53,4 +53,4 @@
"webpack-cli": "^4.10.0",
"webpack-dev-server": "^4.11.1"
}
}
}