6 Commits

14 changed files with 369 additions and 136 deletions

View File

@@ -1,4 +1,16 @@
type IntendedUser = {
name: string;
emails: string[];
emails: OAuthEmail[];
username: string;
};
type OAuthEmail = {
email: string;
primary: boolean;
verified: boolean;
};
type IntendedLink = {
filename: string | null,
filetype: string | null
};

View File

@@ -20,6 +20,7 @@ type AuthPageProps = {
service: string;
recipient: string;
user: IntendedUser | null;
error: string;
};
interface Keys {
@@ -27,16 +28,20 @@ interface Keys {
iv: string;
}
interface Link {
interface LinkFiles {
text: Blob | null;
file: Blob | null;
filename: string | null;
filetype: string | null;
}
const AuthPage = (props: AuthPageProps) => {
const { service, recipient, user } = props;
const [secretFileUrl, _setsecretFileUrl] = useState<string>("#");
const [secretFileUrl, setSecretFileUrl] = useState<string>("#");
const [secretFileName, setSecretFileName] = useState<string>("");
const [secretMessage, setSecretMessage] = useState<string>("Decrypting...");
const [messageRevealed, setMessageRevealed] = useState<boolean>(false);
useEffect(() => {
init().catch((reason) => {
@@ -44,15 +49,26 @@ const AuthPage = (props: AuthPageProps) => {
});
}, []);
const capitalize = (s: string) =>
(s && s[0].toUpperCase() + s.slice(1)) || "";
const init = async (): Promise<void> => {
const link: LinkFiles | null = await retrieveLink();
const keys: Keys | null = await retrieveKeys();
const link: Link | null = await retrieveLink();
if (link && keys) {
await decrypt(link, keys);
}
};
const retrieveLink = async (): Promise<Link | null> => {
const userEmails = (): string[] => {
return user
? user.emails
.filter((email) => email.verified)
.map((email) => email.email)
: [];
};
const retrieveLink = async (): Promise<LinkFiles | null> => {
const urlSegments = new URL(document.URL).pathname.split("/");
const linkId = urlSegments.pop() || urlSegments.pop();
if (!linkId) {
@@ -60,14 +76,26 @@ const AuthPage = (props: AuthPageProps) => {
return null;
}
const textResponse = await fetch(`/uploads/links/${linkId}/text`);
const linkResponse = await fetch(`/links/${linkId}`);
const linkData: IntendedLink = await linkResponse.json();
const textResponse = await fetch(
`/uploads/links/${linkId}/secret_message.txt`
);
const textData = await textResponse.blob();
const fileResponse = await fetch(`/uploads/links/${linkId}/file`);
const fileResponse = await fetch(
`/uploads/links/${linkId}/${linkData.filename}`
);
const fileData = await fileResponse.blob();
if (linkData.filename) {
await setSecretFileName(linkData.filename);
}
return {
text: textData.size > 0 ? textData : null,
file: fileData.size > 0 ? fileData : null,
filename: linkData.filename,
filetype: linkData.filetype,
};
};
@@ -84,6 +112,8 @@ const AuthPage = (props: AuthPageProps) => {
} else {
key = fragmentData[0];
iv = fragmentData[1];
sessionStorage.setItem("link_key", key);
sessionStorage.setItem("link_iv", iv);
}
if (key && iv) {
@@ -94,7 +124,7 @@ const AuthPage = (props: AuthPageProps) => {
}
};
const decrypt = async (link: Link, keys: Keys) => {
const decrypt = async (link: LinkFiles, keys: Keys) => {
const convertedKey = HexMix.hexToUint8(keys.key);
const convertedIv = HexMix.hexToUint8(keys.iv);
const importedKey = await window.crypto.subtle.importKey(
@@ -118,24 +148,76 @@ const AuthPage = (props: AuthPageProps) => {
// And voila
HexMix.arrayBufferToString(encodedText, (result: string) => {
setSecretMessage(result);
setMessageRevealed(true);
});
}
if (link?.file) {
const uploadedFile = await link.file.arrayBuffer();
const encodedFile = await window.crypto.subtle.decrypt(
{
name: "AES-GCM",
length: 256,
iv: convertedIv,
},
importedKey,
uploadedFile
);
const blob = new Blob([encodedFile], {
type: link.filetype ? link.filetype : "text/plain",
});
setSecretFileUrl(window.URL.createObjectURL(blob));
setMessageRevealed(true);
}
};
const renderFooter = (): JSX.Element => {
if (!user) return <div></div>;
return (
<Header3
small
style={{ color: "#CCCCCC", fontSize: "1.4rem", textAlign: "left" }}
>
Hello {user.name}, you are logged in to{" "}
<span style={{ color: "#A849CF" }}>{capitalize(service)}</span> as{" "}
<span style={{ color: "#32EFE7" }}>{user.username}</span>. This account
has the following emails associated with it:
<br />
<br />
<span style={{ color: "#32EFE7" }}>{userEmails().join(", ")}</span>
<br />
<br />
The intended recipient for this message is{" "}
<span style={{ color: "#32EFE7" }}>{recipient}</span> on{" "}
<span style={{ color: "#A849CF" }}>{capitalize(service)}</span>. If you
need to authenticate with a different account, you may do so by logging
out and accessing this link again. It's also possible that you have yet
to verify your email address on{" "}
<span style={{ color: "#A849CF" }}>{capitalize(service)}</span>.
</Header3>
);
};
const renderHeader = (): JSX.Element => {
return (
<div>
<Header2 style={{ margin: ".4rem" }}>Someone sent you a secret</Header2>
<Header2 style={{ margin: ".4rem" }}>
{user ? "You have been identified!" : "Someone sent you a secret"}
</Header2>
{user ? (
<Header3 small>
Hello {user.name}, you are logged in {service} which has the
following verified emails: {user.emails.join(", ")}
{messageRevealed
? "The following message and/or file is for your eyes only."
: "Unfortunately, you are not the intended recipient."}
<br />
<br />
</Header3>
) : (
<Header3 small>
Please verify your identity to reveal this message.
The intended recipient for this message is {recipient} on{" "}
{capitalize(service)}. Please verify your identity to reveal this
message.
</Header3>
)}
</div>
@@ -191,18 +273,22 @@ const AuthPage = (props: AuthPageProps) => {
<TextAlignWrapper align="left">
<Label htmlFor="service">Secret File</Label>
</TextAlignWrapper>
<a href={secretFileUrl} download style={{ width: "100%" }}>
<InputButtonWithIcon
variant="download"
id="downloadfile"
value={secretFileUrl}
value={secretFileName}
onClick={() => {}}
/>
</a>
<Spacer space="3rem" />
<a href={`https://intended.link/auth/${service}`}>
<a href={`https://intended.link/auth/logout`}>
<Button variant="primary" wide onClick={() => {}}>
Re-Verify
Logout
</Button>
</a>
<Spacer space="3rem" />
{renderFooter()}
</CenteredContainer>
</CenteredContainer>
);

View File

@@ -1,80 +1,163 @@
import React, { useEffect, useState } from "react";
import { ProgressIndicator, Header2, Button, IconArrow, Label, FileInput, TextArea, CenteredContainer, Spacer, TextAlignWrapper, GlobalStyle } from '@intended/intended-ui';
import {
ProgressIndicator,
Header2,
Button,
IconArrow,
Label,
FileInput,
TextArea,
CenteredContainer,
Spacer,
TextAlignWrapper,
GlobalStyle,
} from "@intended/intended-ui";
import HexMix from "../utils/hexmix";
type JustPageProps = {
csrf: string
csrf: string;
};
interface AESKey {
key: CryptoKey;
iv: Uint8Array;
exported: ArrayBuffer;
keyHex: string;
ivHex: string;
}
const JustPage = (props: JustPageProps) => {
const [secretInput, setSecretInput] = useState("");
const [fileInput, setFileInput] = useState<File | null>(null);
const [fileInput, setFileInput] = useState<string | null>(null);
const [fileName, setFileName] = useState("");
const [fileType, setFileType] = useState("");
useEffect(() => {
sessionStorage.clear();
}, [])
}, []);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setSecretInput(e.target.value);
};
const handleFile = (file: File) => {
setFileInput(file);
setFileName(file.name);
setFileType(file.type);
if (file instanceof File) {
if (file.size > 2097152) {
// TODO: may not need this check
alert("Error: Max file size is 2mb.");
return;
}
const reader = new FileReader();
reader.onloadend = loadFile;
reader.readAsArrayBuffer(file);
}
};
const loadFile = (fileEvent: ProgressEvent<FileReader>) => {
// Make sure we actually got a binary file
if (
fileEvent &&
fileEvent.target &&
fileEvent.target.result instanceof ArrayBuffer
) {
const data = fileEvent.target.result as ArrayBuffer;
HexMix.arrayBufferToString(data, (result: string) => {
setFileInput(result);
});
} else {
alert("File is either missing or corrupt.");
}
};
const fileFormData = async (form: FormData, aesKey: AESKey) => {
const encoded = HexMix.stringToArrayBuffer(fileInput as string);
const encrypted = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: aesKey.iv,
},
aesKey.key,
encoded
);
const blobData = new Blob([encrypted]);
form.append("file_content", blobData, fileName);
form.append("filename", fileName);
form.append("filetype", fileType);
HexMix.arrayBufferToString(encrypted, (result: string) => {
sessionStorage.setItem("encoded_file", result);
});
};
const textFormData = async (form: FormData, aesKey: AESKey) => {
const encoded = HexMix.stringToArrayBuffer(secretInput);
const encrypted = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: aesKey.iv,
},
aesKey.key,
encoded
);
const blobData = new Blob([encrypted]);
form.append("text_content", blobData, "secret_message.txt");
HexMix.arrayBufferToString(encrypted, (result: string) => {
sessionStorage.setItem("encoded_message", result);
});
};
const createKey = async (): Promise<AESKey> => {
const key = await window.crypto.subtle.generateKey(
{
name: "AES-GCM",
length: 256,
},
true,
["encrypt", "decrypt"]
);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const exported = await window.crypto.subtle.exportKey("raw", key);
const keyHex = HexMix.uint8ToHex(new Uint8Array(exported));
const ivHex = HexMix.uint8ToHex(iv);
return {
key,
iv,
exported,
keyHex,
ivHex,
};
};
const postContents = async () => {
if (!window.crypto.subtle) {
alert('Browser does not support SubtleCrypto');
alert("Browser does not support SubtleCrypto");
return;
}
const key = await window.crypto.subtle.generateKey(
{
name: 'AES-GCM',
length: 256
},
true,
['encrypt', 'decrypt']
);
const encoded = HexMix.stringToArrayBuffer(secretInput);
const iv = window.crypto.getRandomValues(new Uint8Array(16));
const exported = await window.crypto.subtle.exportKey('raw', key);
const encrypted = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv
},
key,
encoded
);
const keyHex = HexMix.uint8ToHex(new Uint8Array(exported));
const ivHex = HexMix.uint8ToHex(iv);
const key = await createKey();
const formData = new FormData();
const blobData = new Blob([encrypted]);
formData.append('text_content', blobData);
// formData.append('filetype', 'text/plain');
// formData.append('filename', 'secret.txt');
await fileFormData(formData, key);
await textFormData(formData, key);
try {
const link: Response = await fetch(`${window.location.origin}/just`, {
headers: {
"X-CSRF-Token": props.csrf
"X-CSRF-Token": props.csrf,
},
body: formData,
method: "POST"
method: "POST",
});
const { id: link_id } = await link.json()
const { id: link_id } = await link.json();
sessionStorage.setItem("link_id", link_id);
sessionStorage.setItem("key_hex", keyHex);
sessionStorage.setItem("iv_hex", ivHex);
sessionStorage.setItem("key_hex", key.keyHex);
sessionStorage.setItem("iv_hex", key.ivHex);
window.location.href = `${window.location.origin}/just/for`;
} catch (err: any) {
alert(err.message);
@@ -103,10 +186,13 @@ const JustPage = (props: JustPageProps) => {
</TextAlignWrapper>
<Spacer space="1.6rem" />
<FileInput id="fileInput" value={fileName} handleFile={handleFile} />
{ fileInput ? "" : ""}
<Spacer space="4rem" />
<div
style={{ display: "flex", justifyContent: "flex-end", width: "100%" }}
style={{
display: "flex",
justifyContent: "flex-end",
width: "100%",
}}
>
<Button variant="secondary" onClick={postContents}>
<IconArrow arrowDirection="right" />

View File

@@ -1,8 +1,30 @@
import React from "react";
import React, { useEffect } from "react";
import { CenteredContainer, SplashIconHeader, Header1, Header3, Spacer, Button, GlobalStyle } from '@intended/intended-ui';
import {
CenteredContainer,
SplashIconHeader,
Header1,
Header3,
Spacer,
Button,
GlobalStyle,
} from "@intended/intended-ui";
type SplashPageProps = {
error: string;
};
const SplashPage = (props: SplashPageProps) => {
useEffect(() => {
displayErrors();
});
const displayErrors = () => {
const { error } = props;
if (error) alert(error);
};
const SplashPage = () => {
return (
<React.StrictMode>
<GlobalStyle />
@@ -15,7 +37,11 @@ const SplashPage = () => {
and secretly.
</Header3>
<Spacer />
<Button variant="secondary" boldFont onClick={() => window.location.href = "/just"}>
<Button
variant="secondary"
boldFont
onClick={() => (window.location.href = "/just")}
>
START SHARING
</Button>
</CenteredContainer>

View File

@@ -1,8 +1,29 @@
import React, { useState } from "react";
import React, { useEffect, useState } from "react";
import { ProgressIndicator, Header2, Button, IconArrow, InputButtonWithIcon, Label, Input, CenteredContainer, SpaceBetweenContainer, Spacer, TextAlignWrapper, GlobalStyle } from "@intended/intended-ui";
import {
ProgressIndicator,
Header2,
Button,
IconArrow,
InputButtonWithIcon,
Label,
Input,
CenteredContainer,
SpaceBetweenContainer,
Spacer,
TextAlignWrapper,
GlobalStyle,
} from "@intended/intended-ui";
const YouPage = () => {
const [url, setUrl] = useState("#");
const [encoded, setEncoded] = useState("");
useEffect(() => {
setUrl(calculateUrl());
setEncoded(calculateEncoded());
}, []);
const calculateUrl = () => {
const linkId = sessionStorage.getItem("link_id");
const keyHex = sessionStorage.getItem("key_hex");
@@ -11,7 +32,11 @@ const YouPage = () => {
return `${window.location.origin}/just/for/you/${linkId}#${keyHex}.${ivHex}`;
};
const [url, _setUrl] = useState(calculateUrl());
const calculateEncoded = () => {
const encodedFile = sessionStorage.getItem("encoded_file");
const encodedMessage = sessionStorage.getItem("encoded_message");
return `${encodedMessage}${encodedFile}`;
};
const copyUrl = async () => {
try {
@@ -19,7 +44,7 @@ const YouPage = () => {
} catch (err: any) {
alert("Could not copy url to clipboard.");
}
}
};
return (
<React.StrictMode>
@@ -47,20 +72,23 @@ const YouPage = () => {
looking eh?:
</Label>
</TextAlignWrapper>
<Input
variant="disabled-light"
id="encodedSecret"
value={url}
/>
<Input variant="disabled-light" id="encodedSecret" value={encoded} />
<Spacer space="3rem" />
<SpaceBetweenContainer>
<Button variant="secondary" onClick={() => window.location.href = "/just/for"}>
<Button
variant="secondary"
onClick={() => (window.location.href = "/just/for")}
>
<IconArrow arrowDirection="left" />
</Button>
<Button onClick={() => {
<Button
onClick={() => {
sessionStorage.clear();
window.location.href = "/just";
}}>Create Another Secret</Button>
}}
>
Create Another Secret
</Button>
</SpaceBetweenContainer>
</CenteredContainer>
</CenteredContainer>

View File

@@ -33,12 +33,21 @@ defmodule Entendu.UserFromAuth do
defp emails_from_auth(_auth), do: []
defp username_from_auth(%Auth{info: %{nickname: username}}), do: username
defp username_from_auth(auth) do
Logger.warn("#{auth.provider} needs to be configured for accessing their username!")
IO.inspect(auth, label: "username_from_auth")
nil
end
defp basic_info(auth) do
%{
id: auth.uid,
name: name_from_auth(auth),
avatar: avatar_from_auth(auth),
emails: emails_from_auth(auth)
emails: emails_from_auth(auth),
username: username_from_auth(auth)
}
end
@@ -58,8 +67,11 @@ defmodule Entendu.UserFromAuth do
end
end
def can_access?(recipient, emails) do
emails
|> Enum.any?(&(&1["verified"] == true and &1["email"] == recipient))
end
def can_access?(recipient, %{emails: emails, username: username}),
do: email_matches?(recipient, emails) || username_matches?(recipient, username)
defp email_matches?(recipient, emails),
do: emails |> Enum.any?(&(&1["verified"] == true and &1["email"] == recipient))
defp username_matches?(recipient, username), do: String.trim(username) === recipient
end

View File

@@ -29,7 +29,7 @@ defmodule EntenduWeb.AuthController do
with %{id: link_id, recipient: recipient} <- link,
{:ok, user} <- UserFromAuth.find_or_create(auth),
true <- UserFromAuth.can_access?(recipient, user.emails) do
true <- UserFromAuth.can_access?(recipient, user) do
# TODO: send over encrypted data that the frontend can decrypt
conn

View File

@@ -47,26 +47,15 @@ defmodule EntenduWeb.LinkController do
def auth_page(conn, %{"id" => link_id}) do
with %Link{service: service, recipient: recipient} = link <- Links.get_link(link_id) do
conn
|> put_session(:intended_link, link)
|> put_session(:intended_link, %{service: service, recipient: recipient})
|> render("auth.html", %{intended_link: %{service: service, recipient: recipient}})
end
end
def text(conn, %{"id" => link_id}) do
with user = get_session(conn, :current_user),
%Link{recipient: recipient} = link <- Links.get_link(link_id),
true <- UserFromAuth.can_access?(recipient, user.emails) do
path = EncryptedLink.url({link.text_content, link})
send_file(conn, 200, path)
end
end
def file(conn, %{"id" => link_id}) do
with user = get_session(conn, :current_user),
%Link{recipient: recipient} = link <- Links.get_link(link_id),
true <- UserFromAuth.can_access?(recipient, user.emails) do
path = EncryptedLink.url({link.file_content, link})
send_file(conn, 200, path)
def authorized_link(conn, %{"id" => link_id}) do
with %Link{} = link <- Links.get_link(link_id) do
conn
|> render("show_authorized.json", %{link: link})
end
end
end

View File

@@ -12,8 +12,12 @@ defmodule EntenduWeb.Plugs.AuthorizeLink do
def init(_params) do
end
def call(conn, params) do
%{params: %{"path" => [_, link_id, _]}} = conn
defp get_link_id(%{params: %{"id" => link_id}}), do: link_id
defp get_link_id(%{params: %{"path" => [_, link_id, _]}}), do: link_id
def call(conn, _params) do
link_id = get_link_id(conn)
user = get_session(conn, :current_user)
if !user do
@@ -23,9 +27,8 @@ defmodule EntenduWeb.Plugs.AuthorizeLink do
|> render("error_code.json", message: "Unauthorized", code: 403)
|> halt
else
with {:ok, user} <- get_user_from_path(conn),
%Link{recipient: recipient} = link <- Links.get_link(link_id),
true <- UserFromAuth.can_access?(recipient, user.emails) do
with %Link{recipient: recipient} = link <- Links.get_link(link_id),
true <- UserFromAuth.can_access?(recipient, user) do
conn
|> assign(:link, link)
else
@@ -52,19 +55,4 @@ defmodule EntenduWeb.Plugs.AuthorizeLink do
end
end
end
defp get_user_from_path(%{params: %{"path" => [_, link_id, _]}} = conn) do
get_session(conn, :current_user)
|> get_user_from_path()
end
defp get_user_from_path(nil) do
{:error, "User not authenticated"}
end
defp get_user_from_path(%{id: _, name: _, emails: _} = user) do
{:ok, user}
end
defp get_user_from_path(_), do: {:error, "Link does not exist"}
end

View File

@@ -16,11 +16,15 @@ defmodule EntenduWeb.Router do
plug :accepts, ["json"]
end
pipeline :authorized_links do
pipeline :authorized_files do
plug AuthorizeLink
plug Plug.Static, at: "/uploads", from: Path.expand('./uploads'), gzip: false
end
pipeline :authorized_link do
plug AuthorizeLink
end
scope "/", EntenduWeb do
pipe_through :browser
@@ -31,23 +35,26 @@ defmodule EntenduWeb.Router do
post "/just/for", LinkController, :for
get "/just/for/you", LinkController, :you_page
get "/just/for/you/:id", LinkController, :auth_page
get "/links/:id/text", LinkController, :text
get "/links/:id/file", LinkController, :file
end
scope "/auth", EntenduWeb do
pipe_through :browser
get "/logout", AuthController, :delete
get "/:provider", AuthController, :request
get "/:provider/callback", AuthController, :callback
delete "/logout", AuthController, :delete
end
scope "/uploads", EntenduWeb do
pipe_through [:browser, :authorized_links]
pipe_through [:browser, :authorized_files]
get "/*path", FileNotFoundController, :index
end
scope "/links", EntenduWeb do
pipe_through [:browser, :authorized_link]
get "/:id", LinkController, :authorized_link
end
# Other scopes may use custom stacks.
# scope "/api", EntenduWeb do
# pipe_through :api

View File

@@ -1,5 +1,3 @@
<main role="main">
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
<%= @inner_content %>
</main>

View File

@@ -3,5 +3,6 @@
service: @intended_link.service,
recipient: @intended_link.recipient,
user: current_user(@conn),
link: current_link(@conn)
link: current_link(@conn),
error: get_flash(@conn, :error)
}) %>

View File

@@ -1,6 +1,6 @@
<section>
<%= react_component("Components.SplashPage") %>
<%= react_component("Components.SplashPage", %{ error: get_flash(@conn, :error) }) %>
<%= if @current_user do %>
<h2>Welcome, <%= @current_user.name %>!</h2>

View File

@@ -31,9 +31,9 @@ defmodule Entendu.EncryptedLink do
# end
# Override the persisted filenames:
def filename(_version, {_file, %{filename: filename}}) do
if filename, do: filename, else: "text"
end
# def filename(_version, {_file, %{filename: filename}}) do
# if filename, do: filename, else: "text"
# end
# Override the storage directory:
def storage_dir(version, {_file, scope}) do