oss.sarwagya.wtf

Recovery

Opt-in Ghost recovery without accounts.

Ghost is still zero-setup. createGhost() works without email, password, OAuth, or recovery.

Recovery is for the moment when your application lets a Ghost own something durable. Ask then:

Want to make this Ghost recoverable?

Enable recovery

const { recoverySecret, recoveryRecord } = await ghost.enableRecovery();

Show recoverySecret once and ask the user to save it outside the browser. Store recoveryRecord next to your app's Ghost row. The record is not secret; it is the public recovery authority state your app needs later.

Recover

import { recoverGhost } from "@0xsarwagya/ghost";
 
const ghost = await recoverGhost({
  recoverySecret,
  recoveryRecord,
});

The recovered browser gets the same ghost.id and a fresh ghost.credentialId. If a different Ghost already lives in this browser, recoverGhost refuses with RECOVERY_FAILED instead of destroying its key — the app must call reset() deliberately first.

Verify recovery on your server

The client-side check above is UX, not security — a hostile client can skip it. Your server must verify the secret itself before it registers the new credential, in one request that also proves possession of the new key:

import {
  deriveRecoveryAuthorityId,
  InMemoryGhostCredentialStore,
  verifyGhostProof,
} from "@0xsarwagya/ghost/server";
 
// POST /ghost/recover  { ghostId, recoverySecret, proof }
async function recoverHandler(body: {
  ghostId: string;
  recoverySecret: string;
  proof: unknown;
}) {
  // 1. The secret must hash to the authority your app stored at setup.
  const record = await db.loadRecoveryRecord(body.ghostId);
  const authorityId = await deriveRecoveryAuthorityId(body.recoverySecret);
  if (record === null || authorityId !== record.authorityId) {
    return { status: 401 };
  }
 
  // 2. The proof must show possession of the NEW credential. Verify it
  //    against a one-off store containing just the claimed credential —
  //    it is not active in your real store yet.
  const pending = new InMemoryGhostCredentialStore();
  const claimed = body.proof as { credentialId: string; publicKey: string };
  pending.register({
    ghostId: body.ghostId,
    credentialId: claimed.credentialId,
    publicKey: claimed.publicKey,
  });
  const result = await verifyGhostProof(body.proof, {
    expectedAudience: AUDIENCE,
    expectedAction: "recover",
    expectedGhostId: body.ghostId,
    challengeStore: store,
    credentialStore: pending,
  });
  if (!result.ok) {
    return { status: 401 };
  }
 
  // 3. Only now persist the credential as active. Your app decides what
  //    happens to the old one: keep, supersede, or revoke.
  await db.addCredential(body.ghostId, result.credentialId, result.publicKey, {
    addedThroughRecovery: true,
  });
  return { status: 200 };
}

The server compares hashes and never stores the secret; the secret crosses the wire once, over TLS, like a password-reset token. The proof rides the normal challenge flow, so credential addition inherits replay protection. Mark recovered credentials (addedThroughRecovery above) — apps are allowed to trust them differently.

Rotating the recovery secret

Calling enableRecovery() again mints a fresh secret and record. Records your app already stores keep verifying against their own secrets — rotation completes only when your app replaces the stored record, and it should do that only for a request authenticated by an active credential.

Recovery does not import the old private key, contact a Ghost service, send email, use OAuth, or silently back anything up. If the user loses the recovery secret, Ghost cannot recreate it.