Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions packages/relay/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,14 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis
fail(403, "FORBIDDEN", "This creation key is not valid.");
}

async function checkAdminToken(token: string, s: SessionRow) {
if (!safeEqual(await sha256(token), s.adminHash)) fail(403, "FORBIDDEN", "This management key is not valid.");
}

async function asAdmin(request: Request, sessionId: string): Promise<SessionRow> {
const token = bearer(request);
const s = await loadSession(sessionId);
if (!safeEqual(await sha256(token), s.adminHash)) fail(403, "FORBIDDEN", "This management key is not valid.");
await checkAdminToken(token, s);
return s;
}

Expand Down Expand Up @@ -191,7 +195,7 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis
const token = bearer(request);
const s = await store.getSession(sessionId);
if (!s) return empty();
if (!safeEqual(await sha256(token), s.adminHash)) fail(403, "FORBIDDEN", "This management key is not valid.");
await checkAdminToken(token, s);
await store.deleteSession(sessionId);
return empty();
}
Expand Down Expand Up @@ -221,7 +225,8 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis
});
}

const refuseDraft = (s: SessionRow, r: SlotRow) => {
// Nothing more can be written once a response is submitted or the session is closed.
const refuseWrite = (s: SessionRow, r: SlotRow) => {
if (r.submission !== null) fail(409, "ALREADY_SUBMITTED", "This response has already been submitted.");
if (s.closedAt !== null) fail(409, "SESSION_CLOSED", "This activity is closed.");
};
Expand All @@ -231,13 +236,13 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis
const { data } = await readBody(request, bodyLimit);
const envelope = envelopeField(data.envelope, limits.maxEnvelopeBytes);
const baseVersion = intField(data.baseVersion, 0, Number.MAX_SAFE_INTEGER, "baseVersion");
refuseDraft(session, slot);
refuseWrite(session, slot);
const updatedAt = clock().toISOString();
if (await store.writeDraft(sessionId, slotId, envelope, baseVersion, updatedAt))
return json({ draftVersion: baseVersion + 1, updatedAt });
const [s, r] = await Promise.all([store.getSession(sessionId), store.getSlot(sessionId, slotId)]);
if (!s || !r) return notFound();
refuseDraft(s, r);
refuseWrite(s, r);
return fail(409, "VERSION_CONFLICT", "This response changed on another device.", { draftVersion: r.draftVersion });
}

Expand All @@ -249,8 +254,7 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis
// An identical retry replays the first result, even after the session closes.
const settled = (s: SessionRow, r: SlotRow) => {
if (r.submissionHash !== null && safeEqual(r.submissionHash, hash)) return json({ submittedAt: r.submittedAt });
if (r.submission !== null) fail(409, "ALREADY_SUBMITTED", "This response has already been submitted.");
if (s.closedAt !== null) fail(409, "SESSION_CLOSED", "This activity is closed.");
refuseWrite(s, r);
return null;
};
const early = settled(session, slot);
Expand Down
3 changes: 1 addition & 2 deletions packages/sealed-envelope/src/envelope.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { decoder, encoder, fail, fromBase64url, randomBytes, toBase64url } from "./util.ts";
import { P256, decoder, encoder, fail, fromBase64url, randomBytes, toBase64url } from "./util.ts";
import type { Bytes } from "./util.ts";
import type { ContentKey, RecipientPrivateKey, RecipientPublicKey } from "./keys.ts";

Expand All @@ -16,7 +16,6 @@ const APP = /^[a-z0-9-]{1,40}$/;
const ID = /^[A-Za-z0-9_-]{22}$/;
const CHANNELS = new Set<string>(["definition", "draft", "submission", "shared"]);
const ALGORITHMS = new Set<string>(["A256GCM", "ECDH-ES+A256GCM"]);
const P256 = { name: "ECDH", namedCurve: "P-256" } as const;
const ECDH_INFO = encoder.encode("malverdesoftware-envelope/v1 ECDH-ES+A256GCM");

function additionalData(context: EnvelopeContext): Bytes {
Expand Down
3 changes: 1 addition & 2 deletions packages/sealed-envelope/src/keys.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { EnvelopeError, fail, fromBase64url, randomBytes, toBase64url, encoder } from "./util.ts";
import { EnvelopeError, P256, fail, fromBase64url, randomBytes, toBase64url, encoder } from "./util.ts";
import type { Bytes } from "./util.ts";

export type ContentKey = CryptoKey & { readonly __brand: "ContentKey" };
export type RecipientPublicKey = CryptoKey & { readonly __brand: "RecipientPublicKey" };
export type RecipientPrivateKey = CryptoKey & { readonly __brand: "RecipientPrivateKey" };

const P256 = { name: "ECDH", namedCurve: "P-256" } as const;
const TOKEN = /^[A-Za-z0-9_-]{1,256}$/;

export const randomSecret = (): string => toBase64url(randomBytes(32));
Expand Down
2 changes: 2 additions & 0 deletions packages/sealed-envelope/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const fail = (code: EnvelopeErrorCode, message: string): never => {
throw new EnvelopeError(code, message);
};

export const P256 = { name: "ECDH", namedCurve: "P-256" } as const;

export const encoder = new TextEncoder();
export const decoder = new TextDecoder("utf-8", { fatal: true });

Expand Down
4 changes: 1 addition & 3 deletions packages/session-client/src/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import type { Envelope } from "@malverdesoftware/sealed-envelope";
import { fail } from "./errors.ts";
import { buildLink, isAllowedUrl } from "./link.ts";
import { APP, ID, SECRET } from "./patterns.ts";
import { isObject } from "./transport.ts";

export type ParticipantEntry = {
Expand Down Expand Up @@ -54,10 +55,7 @@ export type PrepareInput = {
export const FORMAT = "malverdesoftware-session/v1";
export const MAX_PARTICIPANTS = 200;
const MARKER = "<!-- malverdesoftware-session-data: do not edit below this line -->";
const APP = /^[a-z0-9-]{1,40}$/;
const LABEL = /^[A-Za-z0-9 _.-]{1,40}$/;
const ID = /^[A-Za-z0-9_-]{22}$/;
const SECRET = /^[A-Za-z0-9_-]{43}$/;
const PUBLIC_KEY = /^[A-Za-z0-9_-]{87}$/;
const BASE64URL = /^[A-Za-z0-9_-]+$/;
const PASSCODE = /^[0-9]{6}$/;
Expand Down
8 changes: 4 additions & 4 deletions packages/session-client/src/link.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { fail } from "./errors.ts";
import { ID, SECRET } from "./patterns.ts";

export type ParticipantCredentials = { sessionId: string; definitionKey: string; slotSecret: string };

const ID = /^[A-Za-z0-9_-]{22}$/;
const SECRET = /^[A-Za-z0-9_-]{43}$/;

// Everything secret sits after "#", which browsers never send to servers or in Referer headers.
export const buildLink = (participantUrl: string, c: ParticipantCredentials) =>
`${participantUrl}#v1.${c.sessionId}.${c.definitionKey}.${c.slotSecret}`;

export const badLink = (): never => fail("BAD_LINK", "This link is incomplete. Open the full link you were sent.");

export function parseParticipantLink(url: string): ParticipantCredentials {
const text = typeof url === "string" ? url.trim() : "";
const fragment = text.includes("#") ? text.slice(text.indexOf("#") + 1) : "";
const [version, sessionId = "", definitionKey = "", slotSecret = "", ...rest] = fragment.split(".");
if (version !== "v1" || rest.length || !ID.test(sessionId) || !SECRET.test(definitionKey) || !SECRET.test(slotSecret))
fail("BAD_LINK", "This link is incomplete. Open the full link you were sent.");
badLink();
return { sessionId, definitionKey, slotSecret };
}

Expand Down
22 changes: 13 additions & 9 deletions packages/session-client/src/participant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ import {
} from "@malverdesoftware/sealed-envelope";
import type { Channel, Envelope } from "@malverdesoftware/sealed-envelope";
import { SessionError, fail } from "./errors.ts";
import { isAllowedUrl } from "./link.ts";
import { badLink, isAllowedUrl } from "./link.ts";
import type { ParticipantCredentials } from "./link.ts";
import { APP } from "./patterns.ts";
import { defaultStorage, safeStorage } from "./storage.ts";
import type { KeyValueStorage } from "./storage.ts";
import { isEnvelope, isObject, shapes, transport } from "./transport.ts";
Expand Down Expand Up @@ -48,7 +49,6 @@ type LocalState = {
submitted?: { body: unknown; submittedAt: string };
};

const APP = /^[a-z0-9-]{1,40}$/;
const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
const isCode = (error: unknown, code: string): error is SessionError => error instanceof SessionError && error.code === code;

Expand All @@ -71,6 +71,12 @@ function readLocal(text: string | null): LocalState {
}
}

// A save the relay did not confirm stays on the device; the app shows why.
const savedOnDevice = (error: SessionError): SaveResult => ({
state: "SAVED_ON_DEVICE",
reason: error.details?.reason === "SERVER_ERROR" ? "SERVER_ERROR" : "OFFLINE",
});

const sealing = (error: unknown): never => {
if (error instanceof EnvelopeError && error.code === "TOO_LARGE")
fail("TOO_LARGE", "This response is too large to save. Remove some text and try again.");
Expand All @@ -84,15 +90,15 @@ export async function joinSession(credentials: ParticipantCredentials, options:
fail("BAD_INPUT", "The app name or relay address is not valid.");
const { sessionId, definitionKey, slotSecret } = credentials;
const storage = safeStorage(options.storage ?? defaultStorage());
const prefix = `malverdesoftware:${app}:${sessionId}:${await hashToken(slotSecret).catch(() => fail("BAD_LINK", "This link is incomplete. Open the full link you were sent."))}`;
const prefix = `malverdesoftware:${app}:${sessionId}:${await hashToken(slotSecret).catch(badLink)}`;
const passcodeKey = `${prefix}:passcode`;
const stateKey = `${prefix}:state`;

const passcode = options.passcode ?? storage.get(passcodeKey);
if (!passcode) fail("PASSCODE_REQUIRED", "Enter the six-digit passcode you were sent.");
const slot = await deriveSlot(slotSecret, passcode!).catch((error: unknown): never => {
if (error instanceof EnvelopeError && error.code === "BAD_FORMAT") fail("WRONG_PASSCODE", "A passcode is six digits.");
if (error instanceof EnvelopeError) fail("BAD_LINK", "This link is incomplete. Open the full link you were sent.");
if (error instanceof EnvelopeError) badLink();
throw error;
});
const call = transport(relayUrl, options);
Expand All @@ -110,7 +116,7 @@ export async function joinSession(credentials: ParticipantCredentials, options:
});

const unreadable = (): never => fail("DECRYPT_FAILED", "This activity could not be opened. Check that you used the full link.");
const definitionKeyValue = await importContentKey(definitionKey).catch(() => fail("BAD_LINK", "This link is incomplete. Open the full link you were sent."));
const definitionKeyValue = await importContentKey(definitionKey).catch(badLink);
const payload = await open(definitionKeyValue, view.definition, { app, sessionId, channel: "definition" }).catch(unreadable);
if (!isObject(payload) || payload.v !== 1 || typeof payload.researcherPublicKey !== "string" || !("appDefinition" in payload))
unreadable();
Expand Down Expand Up @@ -171,8 +177,7 @@ export async function joinSession(credentials: ParticipantCredentials, options:
confirmDraft(saved.draftVersion);
return { state: "SAVED" };
} catch (error) {
if (isCode(error, "NETWORK"))
return { state: "SAVED_ON_DEVICE", reason: error.details?.reason === "SERVER_ERROR" ? "SERVER_ERROR" : "OFFLINE" };
if (isCode(error, "NETWORK")) return savedOnDevice(error);
if (isCode(error, "VERSION_CONFLICT") && error.details?.draftVersion === state.baseVersion + 1) {
// The outcome of an earlier attempt was unknown; if the stored draft is this one, it was saved.
const latest = await call("GET", path, { token, check: shapes.view });
Expand Down Expand Up @@ -266,8 +271,7 @@ export async function joinSession(credentials: ParticipantCredentials, options:
await sendSubmission();
return { state: "SAVED" };
} catch (error) {
if (isCode(error, "NETWORK"))
return { state: "SAVED_ON_DEVICE", reason: error.details?.reason === "SERVER_ERROR" ? "SERVER_ERROR" : "OFFLINE" };
if (isCode(error, "NETWORK")) return savedOnDevice(error);
throw error;
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/session-client/src/patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Shapes of the identifiers and secrets this package reads. They mirror the relay's and the envelope's own checks.
export const APP = /^[a-z0-9-]{1,40}$/;
export const ID = /^[A-Za-z0-9_-]{22}$/;
export const SECRET = /^[A-Za-z0-9_-]{43}$/;
7 changes: 5 additions & 2 deletions packages/ui/src/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ export function ErrorMessage({ error }: { error: string }) {
) : null;
}

/** For screens people tap: 44px buttons. */
const touchSize = (touch: boolean | undefined) => (touch ? { height: ControlsHeight.L, className: "touch-button" } : {});

/** A Ring button that opens a file picker. */
export function FileButton({ accept, icon, onPick, children }: {
accept: string;
Expand Down Expand Up @@ -73,7 +76,7 @@ export function ConfirmDialog({ show, title, confirmLabel, cancelLabel = "Cancel
onCancel: () => void;
children: ReactNode;
}) {
const size = touch ? { height: ControlsHeight.L, className: "touch-button" } : {};
const size = touchSize(touch);
return (
<Dialog
show={show}
Expand Down Expand Up @@ -117,7 +120,7 @@ export function ConfirmButton({ label, icon, inline, danger, disabled, touch, on
children: ReactNode;
}) {
const [show, setShow] = useState(false);
const size = touch ? { height: ControlsHeight.L, className: "touch-button" } : {};
const size = touchSize(touch);
return (
<>
<Button
Expand Down
Loading