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
1 change: 0 additions & 1 deletion .github/workflows/preview-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ jobs:

- name: Comment PR with Preview Link
if: env.VERCEL_TOKEN_SET == 'true'
uses: actions/github-script@v7
uses: actions/github-script@v9
env:
PREVIEW_URL: ${{ steps.deploy.outputs.preview_url }}
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ no StellarCred account and no server-side credential database. This means:
backup** to download a JSON file of every credential (treat it like a
password — it contains the raw values). Restore on any device with **Import
credential JSON**.
- **Guardian recovery (Shamir Secret Sharing).** On the **Holder** page, click
**Guardian recovery** to split your 256-bit credential-encryption key among
$N$ chosen guardians or devices with a threshold $K$ (e.g. 2-of-3). Guardians
receive only key shares (never credential data). Entering any threshold of
shares reconstructs the key client-side and restores credentials.
- **Move one credential at a time.** A credential's detail view offers
**Transfer to another device**: you pick a passphrase and the app shows a QR
code whose payload is encrypted (AES-256-GCM, PBKDF2 key derivation) before
Expand Down
16 changes: 16 additions & 0 deletions frontend/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,22 @@ fields 33–64 issuer_y (secp256k1 Y, one byte per field in low byte)`}</Code
device, scan the QR and enter the same passphrase to import.
</P>

<SubHeading>Guardian Secret Sharing (Social & Device Recovery)</SubHeading>
<P>
Beyond a single passphrase or plain JSON backup, StellarCred offers{" "}
<strong style={{color:"var(--text)"}}>Guardian recovery</strong> using Shamir Secret Sharing
over GF(256) (see <Code>lib/shamir.ts</Code> and <Code>lib/guardian.ts</Code>).
A holder can split their 256-bit AES credential encryption key among <Code>N</Code> chosen
guardians (friends, family, or secondary hardware devices) such that any <Code>K</Code>{" "}
(threshold) shares can restore the key and decrypt their credentials.
</P>
<P>
The entire process runs client-side. Guardians receive only their assigned key shares
(as JSON files, armored share codes, or QR codes) and never see any credential data.
During recovery, entering any <Code>K</Code> guardian shares reconstructs the encryption key,
authenticates against the encrypted backup, and safely restores credentials back into local storage.
</P>

<SubHeading>What is stored, and where</SubHeading>
<div
style={{
Expand Down
24 changes: 20 additions & 4 deletions frontend/app/holder/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ const ProofPerfPanel = dynamic(
() => import("@/components/ProofPerfPanel").then((m) => m.ProofPerfPanel),
{ ssr: false },
);

// Extracted hooks
import { useCredentialStore } from "@/lib/hooks/useCredentialStore";
import { useBatchSelection } from "@/lib/hooks/useBatchSelection";
Expand All @@ -51,6 +50,7 @@ import { ImportPanel } from "@/components/holder/ImportPanel";
import { ProofFlowView } from "@/components/holder/ProofFlowView";
import { BatchProofFlowView } from "@/components/holder/BatchProofFlowView";
import { SponsorBanner } from "@/components/holder/SponsorBanner";
import { GuardianRecoveryControl } from "@/components/holder/GuardianRecoveryControl";

// Sponsored submission
import { isSponsorAvailable, submitSponsoredProof } from "@/lib/sponsor";
Expand All @@ -77,6 +77,7 @@ function HolderInner() {

const {
creds,
reload: reloadCreds,
save: saveCred,
remove: removeCred,
markCredentialProved,
Expand Down Expand Up @@ -408,10 +409,24 @@ function HolderInner() {
>
<IconDownload size={14} /> Export backup
</button>
<GuardianRecoveryControl
hasCredentials={creds.length > 0}
onRestored={(recovered) => {
reloadCreds();
toast.success(
`Successfully restored ${recovered.length} credential${recovered.length === 1 ? "" : "s"}`,
);
}}
/>
</div>
<p className="faint" style={{ fontSize: "0.75rem", maxWidth: 560, lineHeight: 1.6, margin: 0 }}>
Credentials live only in this browser (localStorage). Export a backup before clearing site data.{" "}
<Link href="/docs#storage" style={{ color: "var(--accent)", textDecoration: "underline" }}>
Credentials live only in this browser (localStorage) — export a backup
or set up <strong>Guardian recovery</strong> (Shamir Secret Sharing) before
clearing site data or switching devices.{" "}
<Link
href="/docs#storage"
style={{ color: "var(--accent)", textDecoration: "underline" }}
>
Where your credentials live
</Link>
</p>
Expand Down Expand Up @@ -439,10 +454,11 @@ function HolderInner() {
onClose={() => setImportPayload(null)}
/>
)}

</>
);
}

export default function HolderPage() {
return <Suspense fallback={null}><HolderInner /></Suspense>;
}
}
41 changes: 41 additions & 0 deletions frontend/components/holder/GuardianRecoveryControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

import dynamic from "next/dynamic";
import { IconShieldLock } from "@tabler/icons-react";
import type { Credential } from "@/lib/credential";
import { useGuardianRecovery } from "@/lib/hooks/useGuardianRecovery";

const GuardianRecoveryModal = dynamic(
() => import("./GuardianRecoveryModal").then((m) => m.GuardianRecoveryModal),
{ ssr: false },
);

export function GuardianRecoveryControl({
hasCredentials,
onRestored,
}: {
hasCredentials: boolean;
onRestored?: (credentials: Credential[]) => void;
}) {
const { activeTab, open, close, handleRestored } = useGuardianRecovery(onRestored);

return (
<>
<button
className="btn btn-ghost btn-sm"
onClick={() => open(hasCredentials ? "setup" : "recover")}
title="Split encryption key among guardians with Shamir secret sharing, or recover credentials"
>
<IconShieldLock size={14} />
Guardian recovery
</button>
{activeTab && (
<GuardianRecoveryModal
initialTab={activeTab}
onClose={close}
onRestored={handleRestored}
/>
)}
</>
);
}
Loading