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
19 changes: 19 additions & 0 deletions .github/workflows/encrypted-storage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
cargo clippy --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked --all-targets -- -D warnings
cargo build --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked
cargo build --manifest-path backend/native/Cargo.toml --locked --bin passshare-mutations
cargo build --locked -p op-cli
- name: Load the pinned PostgreSQL fixture
run: docker pull mirror.gcr.io/library/postgres@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
- name: Verify repeated restore ownership and rejection invariants
Expand All @@ -52,6 +53,15 @@ jobs:
run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --go-session "$RUNNER_TEMP/passshare-go-session.test" backend/native/target/debug/passshare-mutations
- name: Verify account identity with formal Node and Rust Core
run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --account-identity backend/native/target/debug/passshare-mutations
- name: Verify real recipient CLI with formal Node and owned PostgreSQL
env:
PASSSHARE_CLI_DOCKER_MODE: node
PASSSHARE_CLI_NATIVE: ${{ github.workspace }}/backend/native/target/debug/passshare-mutations
PASSSHARE_CLI_BINARY: ${{ github.workspace }}/target/debug/op
PASSSHARE_CLI_OUTPUT_DIR: ${{ runner.temp }}/passshare1163-cli-node
run: |
mkdir "$PASSSHARE_CLI_OUTPUT_DIR"
cargo test --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked sharing_rotation::cli_recipient::formal_cli_recipient -- --ignored --exact --test-threads=1 --nocapture
- name: Verify atomic encrypted attachment storage and rotation with formal Node
run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --encrypted-attachments backend/native/target/debug/passshare-mutations
- name: Verify complete owner migration with the formal Node API
Expand All @@ -70,6 +80,15 @@ jobs:
run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --go-session "$RUNNER_TEMP/passshare-go-session.test" --image passshare-todo1163-encrypted-api
- name: Verify account identity with the distribution image and Rust Core
run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --account-identity --image passshare-todo1163-encrypted-api
- name: Verify real recipient CLI with the distribution image and owned PostgreSQL
env:
PASSSHARE_CLI_DOCKER_MODE: image
PASSSHARE_CLI_IMAGE: passshare-todo1163-encrypted-api
PASSSHARE_CLI_BINARY: ${{ github.workspace }}/target/debug/op
PASSSHARE_CLI_OUTPUT_DIR: ${{ runner.temp }}/passshare1163-cli-image
run: |
mkdir "$PASSSHARE_CLI_OUTPUT_DIR"
cargo test --manifest-path tools/encrypted-storage-acceptance/Cargo.toml --locked sharing_rotation::cli_recipient::formal_cli_recipient -- --ignored --exact --test-threads=1 --nocapture
- name: Verify atomic encrypted attachment storage and rotation with the distribution image
run: tools/encrypted-storage-acceptance/target/debug/passshare-encrypted-storage-acceptance --encrypted-attachments --image passshare-todo1163-encrypted-api
- name: Verify complete owner migration with the distribution image
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"security": {
"csp": {
"default-src": "'self'",
"script-src": "'self'",
"script-src": "'self' 'wasm-unsafe-eval'",
"worker-src": "'self'",
"style-src": "'self'",
"style-src-attr": "'unsafe-inline'",
"img-src": "'self' asset: http://asset.localhost data: blob:",
Expand Down
27 changes: 26 additions & 1 deletion apps/desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@ import { listen } from "@tauri-apps/api/event";
import { AppHeader } from "./components/AppHeader";
import { MainPanel } from "./components/MainPanel";
import { SetupPanel } from "./components/SetupPanel";
import { CloudItemsPanel } from "./components/CloudItemsPanel";
import { useI18n } from "@shared/i18n/I18nProvider";

export function App() {
const { t } = useI18n();
const [cloud, setCloud] = useState(false);
const [switching, setSwitching] = useState(false);
const [cloudError, setCloudError] = useState(false);
const [view, setView] = useState<AppView>("setup");
const [boxPath, setBoxPath] = useState("");
const [masterPassword, setMasterPassword] = useState("");
Expand Down Expand Up @@ -40,6 +46,7 @@ export function App() {
}, []);

function handleLocked() {
window.dispatchEvent(new Event("passshare:cloud-lock"));
generation.current += 1;
setLockGeneration(generation.current);
setMasterPassword("");
Expand All @@ -63,12 +70,30 @@ export function App() {
nativeBinding.current?.accept(await invoke<NativeStatus>("native_session_status"));
}

async function switchCloud() {
if (switching) return;
if (cloud) { setCloud(false); return; }
setSwitching(true); setCloudError(false);
try {
// An account-scoped cloud view must not leave a previously opened .pbox
// or its master-password draft alive behind the account switch.
await invoke<void>("lock_password_box");
handleLocked();
if (!document.hidden) setCloud(true);
} catch { setCloudError(true); }
finally { setSwitching(false); }
}

return (
<div className="ps-app-shell">
<MatrixBackground className="matrix-rain--fixed" />
<main className="ps-app">
<AppHeader />
{view === "setup" ? (
<button type="button" className="ps-button ps-button--secondary" disabled={switching} onClick={() => void switchCloud()}>
{cloud ? t("app.vault.back") : t("cloud.openSubmit")}
</button>
{cloudError && <p className="ps-error" role="alert">{t("cloud.unavailable")}</p>}
{cloud ? <CloudItemsPanel /> : view === "setup" ? (
<SetupPanel
key={lockGeneration}
boxPath={boxPath}
Expand Down
130 changes: 130 additions & 0 deletions apps/desktop/src/components/CloudItemsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { useLayoutEffect, useRef, useState, type FormEvent } from "react";
import { ConnectClient } from "@passshare/connect";
import { useI18n } from "@shared/i18n/I18nProvider";
import { CloudBoxPanel } from "@shared/cloud/CloudBoxPanel";
import { createCloudSession, type CloudSession } from "@shared/cloud/session-transport";
import { CloudApiError } from "@shared/cloud/cloud-api";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useDesktopLabels } from "../hooks/useDesktopLabels";

// Desktop uses the existing token-session login response (mobile transport).
// Nothing is persisted; .pbox, its recovery and save lifecycle stay independent.
export function CloudItemsPanel() {
const { t } = useI18n();
const labels = useDesktopLabels().sync;
const [session, setSession] = useState<CloudSession | null>(null);
const [boxes, setBoxes] = useState<Array<{ id: string; name: string }>>([]);
const [boxId, setBoxId] = useState("");
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const [focused, setFocused] = useState(false);
const nativeFocused = useRef(false);
const lifetime = useRef(0);
const alive = useRef(true);
const binding = useRef<CloudSession | null>(null);
const request = useRef<AbortController | null>(null);
const password = useRef<HTMLInputElement>(null);
const base = useRef<HTMLInputElement>(null);
const email = useRef<HTMLInputElement>(null);
function clear() {
++lifetime.current; request.current?.abort(); request.current = null;
binding.current?.dispose(); binding.current = null;
if (password.current) password.current.value = "";
setSession(null); setBoxes([]); setBoxId(""); setBusy(false);
}
useLayoutEffect(() => {
alive.current = true;
let disposed = false, focusRevision = 0;
const stops: Array<() => void> = [];
const acceptFocus = (value: boolean) => {
if (disposed) return;
nativeFocused.current = value; setFocused(value);
if (!value) window.dispatchEvent(new Event("passshare:cloud-lock"));
};
// Native blur can leave the WebView document visible. Subscribe before the
// initial snapshot, and never let a delayed snapshot overwrite an event.
void (async () => {
try {
const nativeWindow = getCurrentWindow();
for (const [event, value] of [["tauri://blur", false], ["tauri://focus", true]] as const) {
const stop = await nativeWindow.listen(event, () => { ++focusRevision; acceptFocus(value); });
if (disposed) { stop(); return; }
stops.push(stop);
}
const revision = focusRevision;
const value = await nativeWindow.isFocused();
if (revision === focusRevision) acceptFocus(value);
} catch {
for (const stop of stops.splice(0)) stop();
acceptFocus(false);
if (!disposed) setFailed(true);
}
})();
const background = () => { if (document.visibilityState === "hidden") clear(); };
window.addEventListener("pagehide", clear);
window.addEventListener("passshare:cloud-lock", clear);
document.addEventListener("visibilitychange", background);
return () => {
disposed = true; nativeFocused.current = false;
for (const stop of stops.splice(0)) stop();
alive.current = false; clear();
window.removeEventListener("pagehide", clear);
window.removeEventListener("passshare:cloud-lock", clear);
document.removeEventListener("visibilitychange", background);
};
}, []);
async function login(event: FormEvent) {
event.preventDefault();
if (busy || !nativeFocused.current) return;
const generation = ++lifetime.current;
const controller = new AbortController(); request.current = controller;
const current = () => alive.current && nativeFocused.current && generation === lifetime.current && !controller.signal.aborted && document.visibilityState !== "hidden";
let body = JSON.stringify({ email: email.current?.value, password: password.current?.value });
if (password.current) password.current.value = "";
setBusy(true); setFailed(false);
let next: CloudSession | null = null;
try {
const url = new URL(base.current?.value ?? "");
// Validate the same destination as the bounded session transport before login.
const probe = createCloudSession(url.href, "", ""); probe.dispose();
const response = await fetch(new URL("/api/auth/login", url), { method: "POST", body, signal: controller.signal,
credentials: "omit", redirect: "error", cache: "no-store", headers: { "Content-Type": "application/json", "x-passshare-client": "mobile" } });
body = "";
if (response.status !== 200) throw new CloudApiError(response.status);
const auth = await response.json() as { user: { id: string }; refreshToken: string; connectAccessToken: string };
if (!current()) return;
next = createCloudSession(url.href, auth.user.id, auth.refreshToken, current);
binding.current = next;
const client = new ConnectClient({ host: url.origin, token: auth.connectAccessToken });
auth.refreshToken = ""; auth.connectAccessToken = "";
const list = await client.listVaults();
if (!current()) { next.dispose(); return; }
binding.current = next; setSession(next); setBoxes(list); setBoxId(list[0]?.id ?? "");
} catch { next?.dispose(); if (binding.current === next) binding.current = null; if (current()) setFailed(true); }
finally { body = ""; if (current()) { request.current = null; setBusy(false); } }
}
async function logout() {
const held = binding.current;
if (held) {
const response = held.transport("/api/auth/logout", { method: "POST" });
// Hide and clear key/draft state immediately while formal logout runs.
setSession(null); setBoxes([]); setBoxId("");
try { await response; } finally { clear(); }
} else clear();
}
return <section className="ps-card client-cloud">
{!session ? <form onSubmit={(event) => void login(event)}>
<label className="ps-label">{labels.apiUrl}<input className="ps-input" ref={base} type="url" required defaultValue={import.meta.env.VITE_API_BASE_URL ?? ""} disabled={busy} /></label>
<label className="ps-label">{labels.email}<input className="ps-input" ref={email} type="email" required autoComplete="username" autoCapitalize="none" spellCheck={false} disabled={busy} /></label>
<label className="ps-label">{labels.password}<input className="ps-input" ref={password} type="password" required autoComplete="current-password" disabled={busy} /></label>
<button className="ps-button" disabled={busy || !focused}>{t("cloud.openSubmit")}</button>
</form> : <>
<label className="ps-label">{labels.vaultId}<select className="ps-input" value={boxId} onChange={(event) => setBoxId(event.target.value)}>
{boxes.map((box) => <option key={box.id} value={box.id}>{box.name}</option>)}
</select></label>
<button type="button" className="ps-button ps-button--secondary" onClick={() => void logout().catch(() => setFailed(true))}>{t("session.logout")}</button>
{boxId && <CloudBoxPanel key={boxId} session={session} boxId={boxId} split />}
</>}
{failed && <p className="ps-error" role="alert">{t("cloud.failed")}</p>}
</section>;
}
15 changes: 12 additions & 3 deletions apps/desktop/src/lib/cloud-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,19 @@ async function loginAndGetClient(config: CloudSyncConfig): Promise<ConnectClient
assertOk(tokenRes, "Connect token fetch failed");
const tokenBody = (await tokenRes.json()) as { accessToken: string };

return new ConnectClient({
host: config.apiBaseUrl,
token: tokenBody.accessToken,
const client = new ConnectClient({ host: config.apiBaseUrl, token: tokenBody.accessToken });
const stateRes = await syncFetch(`${config.apiBaseUrl}/api/encrypted/boxes/${encodeURIComponent(config.vaultId)}`, {
credentials: "include", cache: "no-store", redirect: "error",
});
if (stateRes.status === 404) {
// Metadata only: the existing DB plaintext boundary rejects encrypted boxes.
await client.getVault(config.vaultId);
} else {
assertOk(stateRes, "Password box mode unavailable");
const state = await stateRes.json() as { mode: string; keyEpoch: number | null };
if (state.mode !== "compatibility" || state.keyEpoch !== null) throw new SyncError("unknown", "Encrypted password box requires encrypted item screen");
}
return client;
}

async function fetchRemoteItems(client: ConnectClient, vaultId: string): Promise<ConnectItem[]> {
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"@shared/*": ["../../shared/*"],
"react": ["./node_modules/@types/react/index.d.ts"],
"react/*": ["./node_modules/@types/react/*"],
"@passshare/connect": ["./node_modules/@passshare/connect"]
"@passshare/connect": ["../../packages/connect-js/dist/client.d.ts"]
}
},
"include": ["src", "../../shared/types", "../../shared/ui"],
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { reactResolveFromApp } from "../../shared/vite/react-resolve";
import { cloudAssets } from "../../shared/vite/cloud-assets";

const rootDir = path.dirname(fileURLToPath(import.meta.url));
const reactResolve = reactResolveFromApp(rootDir);

export default defineConfig({
plugins: [react()],
plugins: [react(), cloudAssets(path.resolve(rootDir, "../.."))],
clearScreen: false,
resolve: {
alias: {
"@passshare/connect": path.resolve(rootDir, "../../packages/connect-js/dist/client.js"),
"@shared": path.resolve(rootDir, "../../shared"),
...reactResolve.alias,
},
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"security": {
"csp": {
"default-src": "'self'",
"script-src": "'self'",
"script-src": "'self' 'wasm-unsafe-eval'",
"worker-src": "'self'",
"style-src": "'self'",
"style-src-attr": "'unsafe-inline'",
"img-src": "'self' asset: http://asset.localhost data: blob:",
Expand Down
23 changes: 20 additions & 3 deletions apps/mobile/src/components/ItemsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,26 @@ import { connectItemFromApi, connectItemToCreateBody } from "../lib/connect-item
import { useAuthSession } from "../hooks/useAuthSession";
import { useItemLabels } from "../hooks/useItemLabels";

import { CloudBoxPanel } from "@shared/cloud/CloudBoxPanel";
type Props = {
vaultId: string;
onBack: () => void;
};

export function ItemsPanel({ vaultId, onBack }: Props) {
export function ItemsPanel(props: Props) {
const { cloudSession, connectClient } = useAuthSession();
const metadataProof = useCallback(async (id: string, signal: AbortSignal) => {
signal.throwIfAborted();
if (!connectClient) throw new Error("Session unavailable");
await connectClient.getVault(id); signal.throwIfAborted();
}, [connectClient]);
const { t } = useI18n();
if (!cloudSession) return <p role="status">{t("session.loading")}</p>;
return <><button type="button" className="ps-button ps-button--secondary" onClick={props.onBack}>{t("app.vault.back")}</button>
<CloudBoxPanel key={`${cloudSession.userId}:${props.vaultId}`} session={cloudSession} boxId={props.vaultId} metadataProof={metadataProof}
compatibility={(ensureCompatibility) => <CompatibilityItemsPanel {...props} ensureCompatibility={ensureCompatibility} />} /></>;
}
function CompatibilityItemsPanel({ vaultId, onBack, ensureCompatibility }: Props & { ensureCompatibility: () => Promise<void> }) {
const { t } = useI18n();
const labels = useItemLabels();
const { user, connectClient, connectLoading } = useAuthSession();
Expand Down Expand Up @@ -54,6 +68,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) {
setError(null);
const active = scope.current;
try {
await ensureCompatibility();
const vault = await connectClient.getVault(vaultId);
if (active !== scope.current) return;
setVaultName(vault.name);
Expand All @@ -67,7 +82,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) {
} catch (err) {
if (active === scope.current) setError(t("app.item.loadError"));
}
}, [connectClient, vaultId, t]);
}, [connectClient, vaultId, t, ensureCompatibility]);

useEffect(() => {
createOperation.current = null;
Expand All @@ -92,6 +107,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) {
const active = scope.current;
createOperation.current ??= crypto.randomUUID();
try {
await ensureCompatibility();
await connectClient.createItem(vaultId, connectItemToCreateBody(values), createOperation.current);
} catch (err) { const conflict = err instanceof Error && "status" in err && err.status === 409;
if (active === scope.current) setError(t(conflict ? "app.item.createConflictError" : "app.item.createResultUnknown")); return; }
Expand All @@ -110,7 +126,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) {
busyRef.current = true;
setBusy(true);
const active = scope.current;
try { await connectClient.editItemForm(vaultId, editingId, editingItem.version, values); }
try { await ensureCompatibility(); await connectClient.editItemForm(vaultId, editingId, editingItem.version, values); }
catch { if (active === scope.current) setError(t("app.item.saveError")); return; }
finally { busyRef.current = false; setBusy(false); }
if (active !== scope.current) return;
Expand All @@ -125,6 +141,7 @@ export function ItemsPanel({ vaultId, onBack }: Props) {
if (!connectClient || busyRef.current || !window.confirm(labels.confirmDelete)) {
return;
}
await ensureCompatibility();
await connectClient.deleteItem(vaultId, itemId);
await loadItems();
}
Expand Down
Loading
Loading