diff --git a/demo/package.json b/demo/package.json index 7de2405..d502fda 100644 --- a/demo/package.json +++ b/demo/package.json @@ -16,7 +16,8 @@ "html5-qrcode": "^2.3.8", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-qrcode-logo": "^3.0.0" + "react-qrcode-logo": "^3.0.0", + "reconnecting-websocket": "^4.4.0" }, "devDependencies": { "@types/react": "^18.3.3", diff --git a/demo/src/ChatMessage.tsx b/demo/src/ChatMessage.tsx index 2a6553a..d39a67a 100644 --- a/demo/src/ChatMessage.tsx +++ b/demo/src/ChatMessage.tsx @@ -4,11 +4,11 @@ import { Flex, Menu, Stack, - Text, rem, Modal, + Badge, } from '@mantine/core'; -import { IconDotsVertical, IconTrash, IconCode } from '@tabler/icons-react'; +import { IconDotsVertical, IconTrash, IconCode, IconCheck } from '@tabler/icons-react'; import { useDisclosure, useHover } from '@mantine/hooks'; interface ChatMessageProps { @@ -40,16 +40,23 @@ export default function ChatMessage({ - - {date.slice(11, 16)} - + } + color="green" + variant="light" + size="sm" + radius="sm" + mb="xs" + > + {date.slice(11, 19)} + diff --git a/demo/src/useStore.ts b/demo/src/useStore.ts index 78f2b29..1ec75a4 100644 --- a/demo/src/useStore.ts +++ b/demo/src/useStore.ts @@ -7,6 +7,14 @@ import { probe_message, } from '../pkg/tsp_demo'; import { bufferToBase64, humanFileSize } from './util'; +import ReconnectingWebSocket from 'reconnecting-websocket'; + +const TIMESTAMP_SERVER = { + id: "did:web:did.tsp-test.org:user:timestamp-server", + publicEnckey: "2SOeMndN9z4oArm7Vu7D7ZGnkbsAXZ2DO-GUAfBd_Bo", + publicSigkey: "HR76y6YG5BWHbj4UQsqX-5ybQPjtETiaZFa4LHWaI68", + transport: "https://tsp-test.org/timestamp" +}; export interface Identity { label: string; @@ -32,16 +40,17 @@ export interface Contact { }; } +type Encoded = string +| { + name: string; + href: string; + size: string; + }; + export interface Message { date: string; message: string; - encoded: - | string - | { - name: string; - href: string; - size: string; - }; + encoded: Encoded; me: boolean; } @@ -88,13 +97,8 @@ type Action = type: 'addMessage'; contactVid: string; message: string; - encoded: - | string - | { - name: string; - href: string; - size: string; - }; + encoded: Encoded; + timestamp: number; me: boolean; } | { type: 'setId'; id: Identity } @@ -150,7 +154,7 @@ function reducer(state: State, action: Action) { messages: [ ...contact.messages, { - date: new Date().toISOString(), + date: (new Date(action.timestamp * 1000)).toISOString(), message: action.message, encoded: action.encoded, me: action.me, @@ -172,7 +176,7 @@ function reducer(state: State, action: Action) { } export default function useStore() { - const ws = useRef(null); + const ws = useRef(null); const store = useRef(new Store()); const [state, dispatch] = useReducer(reducer, loadState()); @@ -243,70 +247,96 @@ export default function useStore() { }); }; + const sendBody = async ( + sender: Identity, + vid: string, + message: string, + encoded: null | Encoded, + body: Uint8Array + ) => { + const d = new Date(); + const timestamp = Math.round(d.getTime() / 1000); + const unencrypted = new TextEncoder().encode(JSON.stringify({ + name: sender.label, + timestamp, + })); + + const { url, sealed } = store.current.seal_message( + sender.vid.id, + vid, + unencrypted, + body + ); + // timestamp sign + const signResponse = await fetch('https://tsp-test.org/sign-timestamp', { + method: 'POST', + body: sealed, + }); + + if (signResponse.status !== 200) { + window.alert(`Failed sending message ${signResponse.statusText}`); + return; + } + + const signed = new Uint8Array(await signResponse.arrayBuffer()); + + const response = await fetch(url, { + method: 'POST', + body: signed, + }); + + if (response.status !== 200) { + window.alert(`Failed sending message ${response.statusText}`); + return; + } + + dispatch({ + type: 'addMessage', + contactVid: vid, + encoded: encoded || await bufferToBase64(sealed), + message, + timestamp, + me: true, + }); + } + const sendMessage = async (vid: string, message: string) => { if (state.id) { const bytes = new TextEncoder().encode(message); const body = new Uint8Array([0, ...bytes]); - const unencrypted = new TextEncoder().encode(state.id.label); - const { url, sealed } = store.current.seal_message( - state.id.vid.id, - vid, - unencrypted, - body - ); - await fetch(url, { - method: 'POST', - body: sealed, - }); - const encoded = await bufferToBase64(sealed); - dispatch({ - type: 'addMessage', - contactVid: vid, - encoded, - message, - me: true, - }); + + sendBody(state.id, vid, message, null, body); } }; const sendFile = async (vid: string, file: File) => { if (state.id && file.name.length > 0) { - const unencrypted = new TextEncoder().encode(state.id.label); const name = new TextEncoder().encode(file.name.slice(0, 254)); const fileBytes = new Uint8Array(await file.arrayBuffer()); const body = new Uint8Array([name.length, ...name, ...fileBytes]); - const { url, sealed } = store.current.seal_message( - state.id.vid.id, - vid, - unencrypted, - body - ); - await fetch(url, { - method: 'POST', - body: sealed, - }); const blob = new Blob([fileBytes], { type: 'application/octet-stream', }); - dispatch({ - type: 'addMessage', - contactVid: vid, - encoded: { - name: file.name, - href: window.URL.createObjectURL(blob), - size: humanFileSize(fileBytes.length), - }, - message: `File: ${file.name} (${humanFileSize(file.size)})`, - me: true, - }); + const encoded = { + name: file.name, + href: window.URL.createObjectURL(blob), + size: humanFileSize(fileBytes.length), + }; + + const message = `File: ${file.name} (${humanFileSize(file.size)})`; + sendBody(state.id, vid, message, encoded, body); } }; // populate the store useEffect(() => { if (store.current) { + // timestamp server vid + store.current.add_verified_vid( + Vid.from_json(JSON.stringify(TIMESTAMP_SERVER)) + ); state.contacts.forEach((contact) => { store.current.add_verified_vid( Vid.from_json(JSON.stringify(contact.vid)) @@ -340,15 +370,27 @@ export default function useStore() { // setup websocket useEffect(() => { if (state.id) { - ws.current = new WebSocket(`wss://tsp-test.org/vid/${state.id.vid.id}`); + ws.current = new ReconnectingWebSocket(`wss://tsp-test.org/vid/${state.id.vid.id}`); const wsCurrent = ws.current; ws.current.onmessage = async (e) => { try { - const bytes = new Uint8Array(await e.data.arrayBuffer()); + // unseal timestamp server message + const tsBytes = new Uint8Array(await e.data.arrayBuffer()); + const tsPlaintext = store.current.open_message(tsBytes); + + if (tsPlaintext.sender !== TIMESTAMP_SERVER.id) { + window.alert('Did not receive message signed by the timestamp server'); + return; + } + + // unseal inner message + const bytes = tsPlaintext.nonconfidential_data; const envelope = JSON.parse(probe_message(bytes)); + const metadata = JSON.parse(envelope.nonconfidential_data); + const timestamp = metadata.timestamp; if (!state.contacts.find((c) => c.vid.id === envelope.sender)) { - addContact(envelope.sender, envelope.nonconfidential_data); + addContact(envelope.sender, metadata.name); } const plaintext = store.current.open_message(bytes); @@ -365,6 +407,7 @@ export default function useStore() { contactVid, message, encoded, + timestamp, me: false, }); } else { @@ -388,6 +431,7 @@ export default function useStore() { href: window.URL.createObjectURL(blob), size: humanFileSize(fileBytes.length), }, + timestamp, me: false, }); } diff --git a/demo/yarn.lock b/demo/yarn.lock index 4fdc792..0bb5a7e 100644 --- a/demo/yarn.lock +++ b/demo/yarn.lock @@ -1732,6 +1732,11 @@ react@^18.3.1: dependencies: loose-envify "^1.1.0" +reconnecting-websocket@^4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783" + integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng== + regenerator-runtime@^0.14.0: version "0.14.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" diff --git a/examples/src/server.rs b/examples/src/server.rs index 0fe129a..692fd89 100644 --- a/examples/src/server.rs +++ b/examples/src/server.rs @@ -2,7 +2,7 @@ use axum::{ body::Bytes, extract::{ ws::{Message, WebSocket}, - Path, State, WebSocketUpgrade, + DefaultBodyLimit, Path, State, WebSocketUpgrade, }, http::{header, StatusCode}, response::{Html, IntoResponse, Response}, @@ -10,10 +10,16 @@ use axum::{ Form, Json, Router, }; use base64ct::{Base64UrlUnpadded, Encoding}; +use core::time; use futures::{sink::SinkExt, stream::StreamExt}; use serde::Deserialize; use serde_json::json; -use std::{collections::HashMap, sync::Arc}; +use std::{ + collections::HashMap, + str::from_utf8, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; use tokio::sync::{broadcast, RwLock}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tsp::{ @@ -45,7 +51,11 @@ struct AppState { #[tokio::main] async fn main() { tracing_subscriber::registry() - .with(tracing_subscriber::fmt::layer()) + .with( + tracing_subscriber::fmt::layer() + .without_time() + .with_ansi(false), + ) .with( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "demo_server=trace,tsp=trace".into()), @@ -74,9 +84,10 @@ async fn main() { .route("/vid/:vid", get(websocket_vid_handler)) .route("/user/:user", get(websocket_user_handler)) .route("/user/:user", post(route_message)) - .route("/timestamp", post(timestamp_message)) + .route("/sign-timestamp", post(sign_timestamp)) .route("/send-message", post(send_message)) .route("/receive-messages", get(websocket_handler)) + .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) .with_state(state); let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); @@ -286,24 +297,77 @@ struct SendMessageForm { receiver: Vid, } -async fn timestamp_message( +#[derive(Deserialize, Debug)] +struct Metadata { + name: String, + timestamp: u64, +} + +async fn sign_timestamp( State(state): State>, body: Bytes, -) -> Result { - let mut bytes: Vec = body.into(); - let timestamp = tsp::cesr::probe(&mut bytes) - .map_err(|_| "Error probing message")? +) -> Result { + let bytes: Vec = body.into(); + let mut header_bytes = bytes.clone(); + let header = tsp::cesr::probe(&mut header_bytes) + .map_err(|_| (StatusCode::BAD_REQUEST, "Error probing message").into_response())?; + + let metadata = header .get_nonconfidential_data() - .ok_or("No nonconfidential data")?; + .ok_or((StatusCode::BAD_REQUEST, "No nonconfidential data").into_response())?; + + let receiver = header + .get_receiver() + .ok_or((StatusCode::BAD_REQUEST, "No receiver set").into_response())?; + + let receiver = from_utf8(receiver) + .map_err(|_| (StatusCode::BAD_REQUEST, "Receiver vid is not valid utf8").into_response())?; - // TODO: check timestamp + let metadata: Metadata = serde_json::from_slice(metadata) + .map_err(|_| (StatusCode::BAD_REQUEST, "Error parsing json").into_response())?; - let response_bytes = state + tracing::info!( + "received timestamp sign request from {}: {}", + metadata.name, + metadata.timestamp + ); + + let start = SystemTime::now(); + let since_the_epoch = start + .duration_since(UNIX_EPOCH) + .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid timestamp").into_response())? + .as_secs(); + let delta = metadata.timestamp.max(since_the_epoch) - metadata.timestamp.min(since_the_epoch); + + if delta > time::Duration::from_secs(60).as_secs() { + tracing::error!("timestamp delta to large: {delta} seconds"); + + return Err((StatusCode::BAD_REQUEST, "Invalid timestamp").into_response()); + } + + tracing::info!("timestamp delta ok: {delta} seconds"); + + let verified_vid = tsp::vid::verify_vid(receiver) + .await + .map_err(|_| (StatusCode::BAD_REQUEST, "Error verifying vid").into_response())?; + state .timestamp_server - .sign_anycast("did:web:did.tsp-test.org:user:timestamp-server", &bytes) - .map_err(|_| "Error signing message")?; + .add_verified_vid(verified_vid) + .map_err(|_| (StatusCode::BAD_REQUEST, "Error adding verified vid").into_response())?; + + let (_url, response_bytes) = state + .timestamp_server + .seal_message( + "did:web:did.tsp-test.org:user:timestamp-server", + receiver, + Some(&bytes), + &[], + ) + .map_err(|_| { + (StatusCode::INTERNAL_SERVER_ERROR, "Error signing message").into_response() + })?; - tracing::debug!("timestamped message"); + tracing::info!("timestamped message"); Ok(response_bytes) } diff --git a/tsp/src/cesr/mod.rs b/tsp/src/cesr/mod.rs index 4f79dfc..f862eae 100644 --- a/tsp/src/cesr/mod.rs +++ b/tsp/src/cesr/mod.rs @@ -77,6 +77,13 @@ pub enum EnvelopeType<'a> { } impl EnvelopeType<'_> { + pub fn get_receiver(&self) -> Option<&[u8]> { + match self { + EnvelopeType::EncryptedMessage { receiver, .. } => Some(*receiver), + EnvelopeType::SignedMessage { receiver, .. } => *receiver, + } + } + pub fn get_nonconfidential_data(&self) -> Option<&[u8]> { match self { EnvelopeType::EncryptedMessage {